@logswap/sdk 0.5.56 → 0.5.57

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.
package/dist/claims.js CHANGED
@@ -59,40 +59,21 @@ export async function isRouterApproved(c, owner) {
59
59
  });
60
60
  }
61
61
  export async function checkReadiness(c, key, owner, need = {}) {
62
- const erc20 = [
63
- {
64
- type: "function",
65
- name: "allowance",
66
- stateMutability: "view",
67
- inputs: [
68
- { name: "owner", type: "address" },
69
- { name: "spender", type: "address" },
70
- ],
71
- outputs: [{ type: "uint256" }],
72
- },
73
- ];
74
- const [operator, baseAllow, quoteAllow] = await Promise.all([
62
+ // A token is ready when BOTH legs of its pull path are: the ERC-20 allowance to whoever pulls
63
+ // (Permit2 when wired) and, with Permit2, its unexpired allowance to the router. This used to
64
+ // read the ERC-20 allowance to the ROUTER, which on a Permit2 deployment is never granted — so
65
+ // it said "approve" forever, and approving the router changed nothing.
66
+ const { checkTokenReadiness } = await import("./onboard.js");
67
+ const ready = async (token, amount) => {
68
+ if (amount === undefined)
69
+ return true;
70
+ const r = await checkTokenReadiness(c, token, owner, amount);
71
+ return !r.needsTokenApproval && !r.needsPermit2Approval;
72
+ };
73
+ const [operator, baseOk, quoteOk] = await Promise.all([
75
74
  need.positions ? isRouterApproved(c, owner) : Promise.resolve(true),
76
- need.base
77
- ? c.public.readContract({
78
- address: key.base,
79
- abi: erc20,
80
- functionName: "allowance",
81
- args: [owner, c.addresses.router],
82
- })
83
- : Promise.resolve(0n),
84
- need.quote
85
- ? c.public.readContract({
86
- address: key.quote,
87
- abi: erc20,
88
- functionName: "allowance",
89
- args: [owner, c.addresses.router],
90
- })
91
- : Promise.resolve(0n),
75
+ ready(key.base, need.base),
76
+ ready(key.quote, need.quote),
92
77
  ]);
93
- return {
94
- needsOperator: !operator,
95
- needsBaseAllowance: need.base !== undefined && baseAllow < need.base,
96
- needsQuoteAllowance: need.quote !== undefined && quoteAllow < need.quote,
97
- };
78
+ return { needsOperator: !operator, needsBaseAllowance: !baseOk, needsQuoteAllowance: !quoteOk };
98
79
  }
package/dist/client.d.ts CHANGED
@@ -85,3 +85,10 @@ export declare const EXPECTED_VERSION = 4n;
85
85
  */
86
86
  export declare const EXPECTED_F_VERSION = 18n;
87
87
  export declare function assertVersion(c: LogswapClient, expected?: bigint): Promise<void>;
88
+ /**
89
+ * The versions say the managers are the right code; this says the rest of the addresses belong
90
+ * to THEM. A stale router or lens from a previous deployment passes every version check and then
91
+ * fails in the worst way: approvals go to a router that pulls for another manager, or every pull
92
+ * reverts because the router's Permit2 is not the one the user approved.
93
+ */
94
+ export declare function assertWiring(c: LogswapClient): Promise<void>;
package/dist/client.js CHANGED
@@ -8,7 +8,39 @@
8
8
  * the protocol, not configuration that grows.
9
9
  */
10
10
  export function createLogswapClient(args) {
11
- return { public: args.public, wallet: args.wallet, addresses: args.addresses };
11
+ return { public: args.public, wallet: args.wallet && guardChain(args.wallet, args.public), addresses: args.addresses };
12
+ }
13
+ /**
14
+ * The wallet, with every signing call first checking that it is on the chain the reads come from.
15
+ *
16
+ * An injected wallet switched to another network would otherwise estimate and simulate against
17
+ * the public client's chain and then sign and send on its own — with deterministic deploy
18
+ * addresses, possibly into a different contract. Checked at each call, not once: a user can
19
+ * switch networks mid-session.
20
+ */
21
+ function guardChain(wallet, pub) {
22
+ if (typeof wallet.getChainId !== "function")
23
+ return wallet; // a test double
24
+ let readChain = pub.chain?.id;
25
+ const check = async () => {
26
+ readChain ??= await pub.getChainId();
27
+ const walletChain = await wallet.getChainId();
28
+ if (walletChain !== readChain) {
29
+ throw new Error(`logswap: the wallet is on chain ${walletChain} but this app reads chain ${readChain} — switch the wallet's network`);
30
+ }
31
+ };
32
+ const guarded = new Set(["writeContract", "sendTransaction", "signTypedData"]);
33
+ return new Proxy(wallet, {
34
+ get(target, prop, receiver) {
35
+ const v = Reflect.get(target, prop, receiver);
36
+ if (!guarded.has(prop) || typeof v !== "function")
37
+ return v;
38
+ return async (...a) => {
39
+ await check();
40
+ return v.apply(target, a);
41
+ };
42
+ },
43
+ });
12
44
  }
13
45
  /**
14
46
  * Assert the deployment matches the ABI this build was generated against.
@@ -114,4 +146,29 @@ export async function assertVersion(c, expected = EXPECTED_VERSION) {
114
146
  `at least F version ${EXPECTED_F_VERSION}. Upgrade the deployment or point at a newer one.`);
115
147
  }
116
148
  }
149
+ await assertWiring(c);
150
+ }
151
+ /**
152
+ * The versions say the managers are the right code; this says the rest of the addresses belong
153
+ * to THEM. A stale router or lens from a previous deployment passes every version check and then
154
+ * fails in the worst way: approvals go to a router that pulls for another manager, or every pull
155
+ * reverts because the router's Permit2 is not the one the user approved.
156
+ */
157
+ export async function assertWiring(c) {
158
+ const { logswapRouterAbi, logswapLensAbi } = await import("./generated.js");
159
+ const rd = (address, abi, functionName) => c.public.readContract({ address, abi, functionName });
160
+ const ZERO = "0x0000000000000000000000000000000000000000";
161
+ const want = [
162
+ ["router.mgr()", rd(c.addresses.router, logswapRouterAbi, "mgr"), c.addresses.cPoolManager],
163
+ ["router.permit2()", rd(c.addresses.router, logswapRouterAbi, "permit2"), c.addresses.permit2 ?? ZERO],
164
+ ["lens.mgr()", rd(c.addresses.lens, logswapLensAbi, "mgr"), c.addresses.cPoolManager],
165
+ ];
166
+ if (c.addresses.fPoolManager) {
167
+ want.push(["router.fmgr()", rd(c.addresses.router, logswapRouterAbi, "fmgr"), c.addresses.fPoolManager]);
168
+ want.push(["lens.fmgr()", rd(c.addresses.lens, logswapLensAbi, "fmgr"), c.addresses.fPoolManager]);
169
+ }
170
+ const got = await Promise.all(want.map((w) => w[1]));
171
+ const bad = want.flatMap(([what, , expected], i) => got[i].toLowerCase() === expected.toLowerCase() ? [] : [`${what} is ${got[i]}, expected ${expected}`]);
172
+ if (bad.length)
173
+ throw new Error(`logswap: the deployment's addresses do not belong together — ${bad.join("; ")}`);
117
174
  }
package/dist/fpool.js CHANGED
@@ -45,7 +45,7 @@ export { fPoolManagerAbi, padPolicyAbi, polPolicyAbi, privatePolicyAbi, publicPo
45
45
  * in for the base — a call through it to any of the four policies decodes the base's functions.
46
46
  */
47
47
  export const fPolicyBaseAbi = polPolicyAbi;
48
- import { signerFor } from "./write.js";
48
+ import { fallBackUnlessRejected, signerFor } from "./write.js";
49
49
  const WAD = 10n ** 18n;
50
50
  const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
51
51
  /** The optional questions a policy declared at birth, as bits of `Pool.asks`. */
@@ -435,7 +435,7 @@ pullTokens = []) {
435
435
  let sendArgs = args;
436
436
  if (pullTokens.length) {
437
437
  const { permit2SigCalls } = await import("./onboard.js");
438
- const sigCalls = await permit2SigCalls(c, pullTokens).catch(() => []);
438
+ const sigCalls = await permit2SigCalls(c, pullTokens).catch(fallBackUnlessRejected);
439
439
  if (sigCalls.length) {
440
440
  const { encodeFunctionData } = await import("viem");
441
441
  const inner = encodeFunctionData({ abi: logswapRouterAbi, functionName, args });
package/dist/onboard.d.ts CHANGED
@@ -18,6 +18,17 @@ import type { LogswapClient } from "./client.js";
18
18
  export declare const MAX_UINT256: bigint;
19
19
  export declare const MAX_UINT160: bigint;
20
20
  export declare const MAX_UINT48: bigint;
21
+ /**
22
+ * How long a Permit2 allowance to the router lives: 30 days, as Uniswap's own interface does. It
23
+ * used to be `MAX_UINT48` — never — so a router bug or a compromised router could pull every
24
+ * approved token from every user indefinitely. A lapsed allowance is re-granted by one more
25
+ * signature, riding inside the next write ({@link permit2SigCalls}).
26
+ */
27
+ export declare const PERMIT2_EXPIRY_SECONDS: bigint;
28
+ /** The expiry to grant now. */
29
+ export declare const permit2Expiry: () => bigint;
30
+ /** Whether a Permit2 allowance (amount, expiration) covers `need` for long enough to land. */
31
+ export declare const permit2Covers: (amount: bigint, expiration: number, need: bigint) => boolean;
21
32
  /** Is this deployment's router wired to Permit2? Decides which approval path applies. */
22
33
  export declare function usesPermit2(c: LogswapClient): boolean;
23
34
  /** The contract that will actually pull tokens: Permit2 when wired, otherwise the router. */
package/dist/onboard.js CHANGED
@@ -18,6 +18,20 @@ import { requireWallet } from "./write.js";
18
18
  export const MAX_UINT256 = (1n << 256n) - 1n;
19
19
  export const MAX_UINT160 = (1n << 160n) - 1n;
20
20
  export const MAX_UINT48 = (1n << 48n) - 1n;
21
+ /**
22
+ * How long a Permit2 allowance to the router lives: 30 days, as Uniswap's own interface does. It
23
+ * used to be `MAX_UINT48` — never — so a router bug or a compromised router could pull every
24
+ * approved token from every user indefinitely. A lapsed allowance is re-granted by one more
25
+ * signature, riding inside the next write ({@link permit2SigCalls}).
26
+ */
27
+ export const PERMIT2_EXPIRY_SECONDS = 30n * 24n * 3600n;
28
+ /** An allowance that lapses within this window is treated as already lapsed: it could die in flight. */
29
+ const EXPIRY_MARGIN_SECONDS = 3600;
30
+ const nowSeconds = () => Math.floor(Date.now() / 1000);
31
+ /** The expiry to grant now. */
32
+ export const permit2Expiry = () => BigInt(nowSeconds()) + PERMIT2_EXPIRY_SECONDS;
33
+ /** Whether a Permit2 allowance (amount, expiration) covers `need` for long enough to land. */
34
+ export const permit2Covers = (amount, expiration, need) => amount >= need && expiration > nowSeconds() + EXPIRY_MARGIN_SECONDS;
21
35
  const ERC20 = parseAbi([
22
36
  "function approve(address spender, uint256 amount) returns (bool)",
23
37
  "function allowance(address owner, address spender) view returns (uint256)",
@@ -54,7 +68,7 @@ export async function approveToken(c, token, amount = MAX_UINT256) {
54
68
  * router, bundled through `multicall`) in production; this transaction form is what a test or a
55
69
  * simple UI wants.
56
70
  */
57
- export async function approvePermit2ForRouter(c, token, amount = MAX_UINT160, expiration = MAX_UINT48) {
71
+ export async function approvePermit2ForRouter(c, token, amount = MAX_UINT160, expiration = permit2Expiry()) {
58
72
  if (!usesPermit2(c))
59
73
  return null;
60
74
  const { account } = requireWallet(c);
@@ -89,13 +103,13 @@ export async function onboardToken(c, token, need) {
89
103
  sent.push(h);
90
104
  }
91
105
  if (usesPermit2(c)) {
92
- const [amt] = (await c.public.readContract({
106
+ const [amt, expiration] = (await c.public.readContract({
93
107
  address: c.addresses.permit2,
94
108
  abi: PERMIT2,
95
109
  functionName: "allowance",
96
110
  args: [address, token, c.addresses.router],
97
111
  }));
98
- if (amt < need) {
112
+ if (!permit2Covers(amt, expiration, need)) {
99
113
  const h = await approvePermit2ForRouter(c, token);
100
114
  if (h) {
101
115
  await c.public.waitForTransactionReceipt({ hash: h });
@@ -114,13 +128,13 @@ export async function checkTokenReadiness(c, token, owner, need) {
114
128
  }));
115
129
  let needsPermit2Approval = false;
116
130
  if (usesPermit2(c)) {
117
- const [amt] = (await c.public.readContract({
131
+ const [amt, expiration] = (await c.public.readContract({
118
132
  address: c.addresses.permit2,
119
133
  abi: PERMIT2,
120
134
  functionName: "allowance",
121
135
  args: [owner, token, c.addresses.router],
122
136
  }));
123
- needsPermit2Approval = amt < need;
137
+ needsPermit2Approval = !permit2Covers(amt, expiration, need);
124
138
  }
125
139
  return { usesPermit2: usesPermit2(c), needsTokenApproval: allowance < need, needsPermit2Approval };
126
140
  }
@@ -156,16 +170,16 @@ export async function permit2SigCalls(c, tokens) {
156
170
  const chainId = await c.public.getChainId();
157
171
  const calls = [];
158
172
  for (const token of [...new Set(tokens.map((t) => t.toLowerCase()))]) {
159
- const [amt, , nonce] = (await c.public.readContract({
173
+ const [amt, expiration, nonce] = (await c.public.readContract({
160
174
  address: c.addresses.permit2,
161
175
  abi: PERMIT2,
162
176
  functionName: "allowance",
163
177
  args: [owner, token, c.addresses.router],
164
178
  }));
165
- if (amt >= 1n << 120n)
166
- continue; // effectively infinite already
179
+ if (permit2Covers(amt, expiration, 1n << 120n))
180
+ continue; // effectively unlimited, and not lapsing
167
181
  const message = {
168
- details: { token, amount: MAX_UINT160, expiration: Number(MAX_UINT48), nonce },
182
+ details: { token, amount: MAX_UINT160, expiration: Number(permit2Expiry()), nonce },
169
183
  spender: c.addresses.router,
170
184
  sigDeadline: BigInt(Math.floor(Date.now() / 1000)) + 1800n,
171
185
  };
package/dist/write.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * raw `minOut`/`maxIn` the contract wants. `withSlippage()` derives one from a quote when that is
13
13
  * what you have — but the bound is what ships.
14
14
  */
15
- import type { Address, Hash } from "viem";
15
+ import type { Address, Hash, Hex } from "viem";
16
16
  import type { LogswapClient } from "./client.js";
17
17
  /** Disables the deadline check. Deliberate opt-out, never a default. */
18
18
  export declare const NEVER: bigint;
@@ -73,6 +73,18 @@ export declare function sendRouterWrite(c: LogswapClient, args: {
73
73
  args: readonly unknown[];
74
74
  bufferPct?: bigint;
75
75
  }): Promise<Hash>;
76
+ /**
77
+ * Whether an error is the user saying no in their wallet (EIP-1193 code 4001, viem's
78
+ * `UserRejectedRequestError`), however deep viem nested it.
79
+ */
80
+ export declare function isUserRejection(e: unknown): boolean;
81
+ /**
82
+ * The permit signatures are an optimisation: when they cannot be produced (a wallet without
83
+ * `signTypedData`, a read that failed) the write goes out plain and relies on a standing
84
+ * allowance. A REJECTED signature is not that — it was swallowed too, and the user who had just
85
+ * said no was shown a transaction prompt, which then reverted with a Permit2 error that hid why.
86
+ */
87
+ export declare function fallBackUnlessRejected(e: unknown): Hex[];
76
88
  /** Simulate a router write and return its decoded result, without sending. */
77
89
  export declare function simulateRouterWrite<T>(c: LogswapClient, args: {
78
90
  abi: readonly unknown[];
package/dist/write.js CHANGED
@@ -92,6 +92,31 @@ export async function sendRouterWrite(c, args) {
92
92
  const gas = (estimate * (100n + (args.bufferPct ?? 30n))) / 100n;
93
93
  return c.wallet.writeContract({ ...params, gas });
94
94
  }
95
+ /**
96
+ * Whether an error is the user saying no in their wallet (EIP-1193 code 4001, viem's
97
+ * `UserRejectedRequestError`), however deep viem nested it.
98
+ */
99
+ export function isUserRejection(e) {
100
+ const seen = new Set();
101
+ for (let x = e; x && typeof x === "object" && !seen.has(x); x = x.cause) {
102
+ seen.add(x);
103
+ const o = x;
104
+ if (o.name === "UserRejectedRequestError" || o.code === 4001)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ /**
110
+ * The permit signatures are an optimisation: when they cannot be produced (a wallet without
111
+ * `signTypedData`, a read that failed) the write goes out plain and relies on a standing
112
+ * allowance. A REJECTED signature is not that — it was swallowed too, and the user who had just
113
+ * said no was shown a transaction prompt, which then reverted with a Permit2 error that hid why.
114
+ */
115
+ export function fallBackUnlessRejected(e) {
116
+ if (isUserRejection(e))
117
+ throw e;
118
+ return [];
119
+ }
95
120
  /** Simulate a router write and return its decoded result, without sending. */
96
121
  export async function simulateRouterWrite(c, args) {
97
122
  const account = args.account ?? c.wallet?.account?.address;
@@ -112,7 +137,7 @@ export async function simulateRouterWrite(c, args) {
112
137
  */
113
138
  export async function sendRouterWriteWithPermits(c, args, pullTokens) {
114
139
  const { permit2SigCalls } = await import("./onboard.js");
115
- const sigCalls = await permit2SigCalls(c, pullTokens).catch(() => []);
140
+ const sigCalls = await permit2SigCalls(c, pullTokens).catch(fallBackUnlessRejected);
116
141
  if (sigCalls.length === 0)
117
142
  return sendRouterWrite(c, args);
118
143
  const { encodeFunctionData } = await import("viem");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logswap/sdk",
3
- "version": "0.5.56",
3
+ "version": "0.5.57",
4
4
  "description": "TypeScript SDK for Logswap, the liquidity primitive introducing the logarithmic invariant — typed wrappers over the CPoolManager / FPoolManager / Router / Lens surface.",
5
5
  "license": "GPL-2.0-or-later",
6
6
  "type": "module",
package/src/claims.ts CHANGED
@@ -91,42 +91,20 @@ export async function checkReadiness(
91
91
  owner: Address,
92
92
  need: { base?: bigint; quote?: bigint; positions?: boolean } = {},
93
93
  ): Promise<Readiness> {
94
- const erc20 = [
95
- {
96
- type: "function",
97
- name: "allowance",
98
- stateMutability: "view",
99
- inputs: [
100
- { name: "owner", type: "address" },
101
- { name: "spender", type: "address" },
102
- ],
103
- outputs: [{ type: "uint256" }],
104
- },
105
- ] as const;
106
-
107
- const [operator, baseAllow, quoteAllow] = await Promise.all([
94
+ // A token is ready when BOTH legs of its pull path are: the ERC-20 allowance to whoever pulls
95
+ // (Permit2 when wired) and, with Permit2, its unexpired allowance to the router. This used to
96
+ // read the ERC-20 allowance to the ROUTER, which on a Permit2 deployment is never granted — so
97
+ // it said "approve" forever, and approving the router changed nothing.
98
+ const { checkTokenReadiness } = await import("./onboard.js");
99
+ const ready = async (token: Address, amount?: bigint) => {
100
+ if (amount === undefined) return true;
101
+ const r = await checkTokenReadiness(c, token, owner, amount);
102
+ return !r.needsTokenApproval && !r.needsPermit2Approval;
103
+ };
104
+ const [operator, baseOk, quoteOk] = await Promise.all([
108
105
  need.positions ? isRouterApproved(c, owner) : Promise.resolve(true),
109
- need.base
110
- ? (c.public.readContract({
111
- address: key.base,
112
- abi: erc20,
113
- functionName: "allowance",
114
- args: [owner, c.addresses.router],
115
- }) as Promise<bigint>)
116
- : Promise.resolve(0n),
117
- need.quote
118
- ? (c.public.readContract({
119
- address: key.quote,
120
- abi: erc20,
121
- functionName: "allowance",
122
- args: [owner, c.addresses.router],
123
- }) as Promise<bigint>)
124
- : Promise.resolve(0n),
106
+ ready(key.base, need.base),
107
+ ready(key.quote, need.quote),
125
108
  ]);
126
-
127
- return {
128
- needsOperator: !operator,
129
- needsBaseAllowance: need.base !== undefined && baseAllow < need.base,
130
- needsQuoteAllowance: need.quote !== undefined && quoteAllow < need.quote,
131
- };
109
+ return { needsOperator: !operator, needsBaseAllowance: !baseOk, needsQuoteAllowance: !quoteOk };
132
110
  }
package/src/client.ts CHANGED
@@ -52,7 +52,38 @@ export function createLogswapClient(args: {
52
52
  wallet?: WalletClient;
53
53
  addresses: LogswapAddresses;
54
54
  }): LogswapClient {
55
- return { public: args.public, wallet: args.wallet, addresses: args.addresses };
55
+ return { public: args.public, wallet: args.wallet && guardChain(args.wallet, args.public), addresses: args.addresses };
56
+ }
57
+
58
+ /**
59
+ * The wallet, with every signing call first checking that it is on the chain the reads come from.
60
+ *
61
+ * An injected wallet switched to another network would otherwise estimate and simulate against
62
+ * the public client's chain and then sign and send on its own — with deterministic deploy
63
+ * addresses, possibly into a different contract. Checked at each call, not once: a user can
64
+ * switch networks mid-session.
65
+ */
66
+ function guardChain(wallet: WalletClient, pub: PublicClient): WalletClient {
67
+ if (typeof wallet.getChainId !== "function") return wallet; // a test double
68
+ let readChain: number | undefined = pub.chain?.id;
69
+ const check = async () => {
70
+ readChain ??= await pub.getChainId();
71
+ const walletChain = await wallet.getChainId();
72
+ if (walletChain !== readChain) {
73
+ throw new Error(`logswap: the wallet is on chain ${walletChain} but this app reads chain ${readChain} — switch the wallet's network`);
74
+ }
75
+ };
76
+ const guarded = new Set<PropertyKey>(["writeContract", "sendTransaction", "signTypedData"]);
77
+ return new Proxy(wallet, {
78
+ get(target, prop, receiver) {
79
+ const v = Reflect.get(target, prop, receiver);
80
+ if (!guarded.has(prop) || typeof v !== "function") return v;
81
+ return async (...a: unknown[]) => {
82
+ await check();
83
+ return (v as (...x: unknown[]) => unknown).apply(target, a);
84
+ };
85
+ },
86
+ });
56
87
  }
57
88
 
58
89
  /**
@@ -164,4 +195,32 @@ export async function assertVersion(c: LogswapClient, expected = EXPECTED_VERSIO
164
195
  );
165
196
  }
166
197
  }
198
+ await assertWiring(c);
199
+ }
200
+
201
+ /**
202
+ * The versions say the managers are the right code; this says the rest of the addresses belong
203
+ * to THEM. A stale router or lens from a previous deployment passes every version check and then
204
+ * fails in the worst way: approvals go to a router that pulls for another manager, or every pull
205
+ * reverts because the router's Permit2 is not the one the user approved.
206
+ */
207
+ export async function assertWiring(c: LogswapClient): Promise<void> {
208
+ const { logswapRouterAbi, logswapLensAbi } = await import("./generated.js");
209
+ const rd = (address: Address, abi: readonly unknown[], functionName: string) =>
210
+ c.public.readContract({ address, abi, functionName } as never) as Promise<Address>;
211
+ const ZERO = "0x0000000000000000000000000000000000000000";
212
+ const want: Array<[string, Promise<Address>, Address]> = [
213
+ ["router.mgr()", rd(c.addresses.router, logswapRouterAbi, "mgr"), c.addresses.cPoolManager],
214
+ ["router.permit2()", rd(c.addresses.router, logswapRouterAbi, "permit2"), c.addresses.permit2 ?? ZERO],
215
+ ["lens.mgr()", rd(c.addresses.lens, logswapLensAbi, "mgr"), c.addresses.cPoolManager],
216
+ ];
217
+ if (c.addresses.fPoolManager) {
218
+ want.push(["router.fmgr()", rd(c.addresses.router, logswapRouterAbi, "fmgr"), c.addresses.fPoolManager]);
219
+ want.push(["lens.fmgr()", rd(c.addresses.lens, logswapLensAbi, "fmgr"), c.addresses.fPoolManager]);
220
+ }
221
+ const got = await Promise.all(want.map((w) => w[1]));
222
+ const bad = want.flatMap(([what, , expected], i) =>
223
+ got[i]!.toLowerCase() === expected.toLowerCase() ? [] : [`${what} is ${got[i]}, expected ${expected}`],
224
+ );
225
+ if (bad.length) throw new Error(`logswap: the deployment's addresses do not belong together — ${bad.join("; ")}`);
167
226
  }
package/src/fpool.ts CHANGED
@@ -48,7 +48,7 @@ export { fPoolManagerAbi, padPolicyAbi, polPolicyAbi, privatePolicyAbi, publicPo
48
48
  */
49
49
  export const fPolicyBaseAbi = polPolicyAbi;
50
50
  import type { LogswapClient } from "./client.js";
51
- import { signerFor } from "./write.js";
51
+ import { fallBackUnlessRejected, signerFor } from "./write.js";
52
52
 
53
53
  const WAD = 10n ** 18n;
54
54
  const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const;
@@ -676,7 +676,7 @@ async function writeRouter(
676
676
  let sendArgs: unknown[] = args;
677
677
  if (pullTokens.length) {
678
678
  const { permit2SigCalls } = await import("./onboard.js");
679
- const sigCalls = await permit2SigCalls(c, pullTokens).catch(() => []);
679
+ const sigCalls = await permit2SigCalls(c, pullTokens).catch(fallBackUnlessRejected);
680
680
  if (sigCalls.length) {
681
681
  const { encodeFunctionData } = await import("viem");
682
682
  const inner = encodeFunctionData({ abi: logswapRouterAbi, functionName, args } as never);
package/src/onboard.ts CHANGED
@@ -22,6 +22,23 @@ export const MAX_UINT256 = (1n << 256n) - 1n;
22
22
  export const MAX_UINT160 = (1n << 160n) - 1n;
23
23
  export const MAX_UINT48 = (1n << 48n) - 1n;
24
24
 
25
+ /**
26
+ * How long a Permit2 allowance to the router lives: 30 days, as Uniswap's own interface does. It
27
+ * used to be `MAX_UINT48` — never — so a router bug or a compromised router could pull every
28
+ * approved token from every user indefinitely. A lapsed allowance is re-granted by one more
29
+ * signature, riding inside the next write ({@link permit2SigCalls}).
30
+ */
31
+ export const PERMIT2_EXPIRY_SECONDS = 30n * 24n * 3600n;
32
+ /** An allowance that lapses within this window is treated as already lapsed: it could die in flight. */
33
+ const EXPIRY_MARGIN_SECONDS = 3600;
34
+
35
+ const nowSeconds = () => Math.floor(Date.now() / 1000);
36
+ /** The expiry to grant now. */
37
+ export const permit2Expiry = (): bigint => BigInt(nowSeconds()) + PERMIT2_EXPIRY_SECONDS;
38
+ /** Whether a Permit2 allowance (amount, expiration) covers `need` for long enough to land. */
39
+ export const permit2Covers = (amount: bigint, expiration: number, need: bigint): boolean =>
40
+ amount >= need && expiration > nowSeconds() + EXPIRY_MARGIN_SECONDS;
41
+
25
42
  const ERC20 = parseAbi([
26
43
  "function approve(address spender, uint256 amount) returns (bool)",
27
44
  "function allowance(address owner, address spender) view returns (uint256)",
@@ -71,7 +88,7 @@ export async function approvePermit2ForRouter(
71
88
  c: LogswapClient,
72
89
  token: Address,
73
90
  amount: bigint = MAX_UINT160,
74
- expiration: bigint = MAX_UINT48,
91
+ expiration: bigint = permit2Expiry(),
75
92
  ): Promise<Hash | null> {
76
93
  if (!usesPermit2(c)) return null;
77
94
  const { account } = requireWallet(c);
@@ -109,13 +126,13 @@ export async function onboardToken(c: LogswapClient, token: Address, need: bigin
109
126
  }
110
127
 
111
128
  if (usesPermit2(c)) {
112
- const [amt] = (await c.public.readContract({
129
+ const [amt, expiration] = (await c.public.readContract({
113
130
  address: c.addresses.permit2!,
114
131
  abi: PERMIT2,
115
132
  functionName: "allowance",
116
133
  args: [address, token, c.addresses.router],
117
134
  })) as readonly [bigint, number, number];
118
- if (amt < need) {
135
+ if (!permit2Covers(amt, expiration, need)) {
119
136
  const h = await approvePermit2ForRouter(c, token);
120
137
  if (h) {
121
138
  await c.public.waitForTransactionReceipt({ hash: h });
@@ -150,13 +167,13 @@ export async function checkTokenReadiness(
150
167
 
151
168
  let needsPermit2Approval = false;
152
169
  if (usesPermit2(c)) {
153
- const [amt] = (await c.public.readContract({
170
+ const [amt, expiration] = (await c.public.readContract({
154
171
  address: c.addresses.permit2!,
155
172
  abi: PERMIT2,
156
173
  functionName: "allowance",
157
174
  args: [owner, token, c.addresses.router],
158
175
  })) as readonly [bigint, number, number];
159
- needsPermit2Approval = amt < need;
176
+ needsPermit2Approval = !permit2Covers(amt, expiration, need);
160
177
  }
161
178
 
162
179
  return { usesPermit2: usesPermit2(c), needsTokenApproval: allowance < need, needsPermit2Approval };
@@ -195,15 +212,15 @@ export async function permit2SigCalls(c: LogswapClient, tokens: Address[]): Prom
195
212
  const chainId = await c.public.getChainId();
196
213
  const calls: Hex[] = [];
197
214
  for (const token of [...new Set(tokens.map((t) => t.toLowerCase() as Address))]) {
198
- const [amt, , nonce] = (await c.public.readContract({
215
+ const [amt, expiration, nonce] = (await c.public.readContract({
199
216
  address: c.addresses.permit2!,
200
217
  abi: PERMIT2,
201
218
  functionName: "allowance",
202
219
  args: [owner, token, c.addresses.router],
203
220
  })) as readonly [bigint, number, number];
204
- if (amt >= 1n << 120n) continue; // effectively infinite already
221
+ if (permit2Covers(amt, expiration, 1n << 120n)) continue; // effectively unlimited, and not lapsing
205
222
  const message = {
206
- details: { token, amount: MAX_UINT160, expiration: Number(MAX_UINT48), nonce },
223
+ details: { token, amount: MAX_UINT160, expiration: Number(permit2Expiry()), nonce },
207
224
  spender: c.addresses.router,
208
225
  sigDeadline: BigInt(Math.floor(Date.now() / 1000)) + 1800n,
209
226
  };
package/src/write.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * what you have — but the bound is what ships.
14
14
  */
15
15
 
16
- import type { Address, Hash } from "viem";
16
+ import type { Address, Hash, Hex } from "viem";
17
17
  import type { LogswapClient } from "./client.js";
18
18
 
19
19
  /** Disables the deadline check. Deliberate opt-out, never a default. */
@@ -114,6 +114,31 @@ export async function sendRouterWrite(
114
114
  return c.wallet!.writeContract({ ...(params as object), gas } as never);
115
115
  }
116
116
 
117
+ /**
118
+ * Whether an error is the user saying no in their wallet (EIP-1193 code 4001, viem's
119
+ * `UserRejectedRequestError`), however deep viem nested it.
120
+ */
121
+ export function isUserRejection(e: unknown): boolean {
122
+ const seen = new Set<unknown>();
123
+ for (let x: unknown = e; x && typeof x === "object" && !seen.has(x); x = (x as { cause?: unknown }).cause) {
124
+ seen.add(x);
125
+ const o = x as { name?: string; code?: number };
126
+ if (o.name === "UserRejectedRequestError" || o.code === 4001) return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * The permit signatures are an optimisation: when they cannot be produced (a wallet without
133
+ * `signTypedData`, a read that failed) the write goes out plain and relies on a standing
134
+ * allowance. A REJECTED signature is not that — it was swallowed too, and the user who had just
135
+ * said no was shown a transaction prompt, which then reverted with a Permit2 error that hid why.
136
+ */
137
+ export function fallBackUnlessRejected(e: unknown): Hex[] {
138
+ if (isUserRejection(e)) throw e;
139
+ return [];
140
+ }
141
+
117
142
  /** Simulate a router write and return its decoded result, without sending. */
118
143
  export async function simulateRouterWrite<T>(
119
144
  c: LogswapClient,
@@ -142,7 +167,7 @@ export async function sendRouterWriteWithPermits(
142
167
  pullTokens: Address[],
143
168
  ): Promise<Hash> {
144
169
  const { permit2SigCalls } = await import("./onboard.js");
145
- const sigCalls = await permit2SigCalls(c, pullTokens).catch(() => []);
170
+ const sigCalls = await permit2SigCalls(c, pullTokens).catch(fallBackUnlessRejected);
146
171
  if (sigCalls.length === 0) return sendRouterWrite(c, args);
147
172
  const { encodeFunctionData } = await import("viem");
148
173
  const inner = encodeFunctionData({ abi: args.abi, functionName: args.functionName, args: args.args } as never);