sally-defi-ts-sdk 0.3.2

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -0
  3. package/dist/aio/client.d.ts +93 -0
  4. package/dist/aio/client.d.ts.map +1 -0
  5. package/dist/aio/client.js +283 -0
  6. package/dist/aio/client.js.map +1 -0
  7. package/dist/aio/index.d.ts +20 -0
  8. package/dist/aio/index.d.ts.map +1 -0
  9. package/dist/aio/index.js +19 -0
  10. package/dist/aio/index.js.map +1 -0
  11. package/dist/aio/modules/fees.d.ts +19 -0
  12. package/dist/aio/modules/fees.d.ts.map +1 -0
  13. package/dist/aio/modules/fees.js +47 -0
  14. package/dist/aio/modules/fees.js.map +1 -0
  15. package/dist/aio/modules/liquidity.d.ts +47 -0
  16. package/dist/aio/modules/liquidity.d.ts.map +1 -0
  17. package/dist/aio/modules/liquidity.js +115 -0
  18. package/dist/aio/modules/liquidity.js.map +1 -0
  19. package/dist/aio/modules/prices.d.ts +18 -0
  20. package/dist/aio/modules/prices.d.ts.map +1 -0
  21. package/dist/aio/modules/prices.js +48 -0
  22. package/dist/aio/modules/prices.js.map +1 -0
  23. package/dist/aio/modules/swap.d.ts +50 -0
  24. package/dist/aio/modules/swap.d.ts.map +1 -0
  25. package/dist/aio/modules/swap.js +267 -0
  26. package/dist/aio/modules/swap.js.map +1 -0
  27. package/dist/aio/modules/wallet.d.ts +13 -0
  28. package/dist/aio/modules/wallet.d.ts.map +1 -0
  29. package/dist/aio/modules/wallet.js +27 -0
  30. package/dist/aio/modules/wallet.js.map +1 -0
  31. package/dist/aio/token.d.ts +19 -0
  32. package/dist/aio/token.d.ts.map +1 -0
  33. package/dist/aio/token.js +50 -0
  34. package/dist/aio/token.js.map +1 -0
  35. package/dist/client.d.ts +142 -0
  36. package/dist/client.d.ts.map +1 -0
  37. package/dist/client.js +452 -0
  38. package/dist/client.js.map +1 -0
  39. package/dist/constants.d.ts +36 -0
  40. package/dist/constants.d.ts.map +1 -0
  41. package/dist/constants.js +39 -0
  42. package/dist/constants.js.map +1 -0
  43. package/dist/data/deployment.json +1 -0
  44. package/dist/deployment.d.ts +44 -0
  45. package/dist/deployment.d.ts.map +1 -0
  46. package/dist/deployment.js +118 -0
  47. package/dist/deployment.js.map +1 -0
  48. package/dist/errors.d.ts +57 -0
  49. package/dist/errors.d.ts.map +1 -0
  50. package/dist/errors.js +197 -0
  51. package/dist/errors.js.map +1 -0
  52. package/dist/index.d.ts +48 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +47 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/modules/fees.d.ts +32 -0
  57. package/dist/modules/fees.d.ts.map +1 -0
  58. package/dist/modules/fees.js +64 -0
  59. package/dist/modules/fees.js.map +1 -0
  60. package/dist/modules/liquidity.d.ts +134 -0
  61. package/dist/modules/liquidity.d.ts.map +1 -0
  62. package/dist/modules/liquidity.js +277 -0
  63. package/dist/modules/liquidity.js.map +1 -0
  64. package/dist/modules/prices.d.ts +47 -0
  65. package/dist/modules/prices.d.ts.map +1 -0
  66. package/dist/modules/prices.js +85 -0
  67. package/dist/modules/prices.js.map +1 -0
  68. package/dist/modules/swap.d.ts +102 -0
  69. package/dist/modules/swap.d.ts.map +1 -0
  70. package/dist/modules/swap.js +400 -0
  71. package/dist/modules/swap.js.map +1 -0
  72. package/dist/modules/wallet.d.ts +16 -0
  73. package/dist/modules/wallet.d.ts.map +1 -0
  74. package/dist/modules/wallet.js +30 -0
  75. package/dist/modules/wallet.js.map +1 -0
  76. package/dist/permit2.d.ts +97 -0
  77. package/dist/permit2.d.ts.map +1 -0
  78. package/dist/permit2.js +130 -0
  79. package/dist/permit2.js.map +1 -0
  80. package/dist/previews.d.ts +57 -0
  81. package/dist/previews.d.ts.map +1 -0
  82. package/dist/previews.js +69 -0
  83. package/dist/previews.js.map +1 -0
  84. package/dist/safety.d.ts +80 -0
  85. package/dist/safety.d.ts.map +1 -0
  86. package/dist/safety.js +133 -0
  87. package/dist/safety.js.map +1 -0
  88. package/dist/token.d.ts +215 -0
  89. package/dist/token.d.ts.map +1 -0
  90. package/dist/token.js +239 -0
  91. package/dist/token.js.map +1 -0
  92. package/dist/types.d.ts +229 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +462 -0
  95. package/dist/types.js.map +1 -0
  96. package/dist/util.d.ts +13 -0
  97. package/dist/util.d.ts.map +1 -0
  98. package/dist/util.js +22 -0
  99. package/dist/util.js.map +1 -0
  100. package/package.json +48 -0
  101. package/src/aio/client.ts +329 -0
  102. package/src/aio/index.ts +20 -0
  103. package/src/aio/modules/fees.ts +60 -0
  104. package/src/aio/modules/liquidity.ts +181 -0
  105. package/src/aio/modules/prices.ts +57 -0
  106. package/src/aio/modules/swap.ts +347 -0
  107. package/src/aio/modules/wallet.ts +34 -0
  108. package/src/aio/token.ts +59 -0
  109. package/src/client.ts +526 -0
  110. package/src/constants.ts +43 -0
  111. package/src/data/deployment.json +1 -0
  112. package/src/deployment.ts +132 -0
  113. package/src/errors.ts +215 -0
  114. package/src/index.ts +90 -0
  115. package/src/modules/fees.ts +78 -0
  116. package/src/modules/liquidity.ts +446 -0
  117. package/src/modules/prices.ts +97 -0
  118. package/src/modules/swap.ts +502 -0
  119. package/src/modules/wallet.ts +37 -0
  120. package/src/permit2.ts +169 -0
  121. package/src/previews.ts +95 -0
  122. package/src/safety.ts +152 -0
  123. package/src/token.ts +254 -0
  124. package/src/types.ts +438 -0
  125. package/src/util.ts +20 -0
package/src/permit2.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Permit2 + EIP-2612 signature approvals.
3
+ *
4
+ * Two distinct things live here:
5
+ *
6
+ * - **EIP-2612** (`signPermit2612`) — the token-native `permit(owner,spender,
7
+ * value,nonce,deadline,v,r,s)`. Used by {@link Token.permit} so a swap can
8
+ * authorise the proxy with a *signature* instead of a separate `approve` tx, on
9
+ * tokens that support it (USDC, DAI-family wrappers, many others).
10
+ *
11
+ * - **Permit2** (`Permit2`, `0x000000000022D473030F116dDEE9F6B43aC78BA3`) —
12
+ * Uniswap's universal approval contract. Sally's controller uses it
13
+ * *internally* for the V4 PositionManager pull; user swaps do not require it.
14
+ *
15
+ * Both produce EIP-712 signatures via ethers; nothing is broadcast here.
16
+ */
17
+
18
+ import { Contract, getAddress, Signature, type Signer } from "ethers";
19
+ import type { SallyClient } from "./client.js";
20
+
21
+ // Canonical Permit2, identical on every EVM chain (CREATE2).
22
+ export const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
23
+ export const MAX_UINT160 = 2n ** 160n - 1n;
24
+ export const MAX_UINT48 = 2n ** 48n - 1n;
25
+
26
+ // --------------------------------------------------------------------------- //
27
+ // EIP-2612
28
+ // --------------------------------------------------------------------------- //
29
+ /** Sign an EIP-2612 `Permit` and return `[v, r, s]` (v number, r/s 0x-hex bytes32). */
30
+ export async function signPermit2612(
31
+ account: Signer,
32
+ opts: {
33
+ token: string;
34
+ tokenName: string;
35
+ version: string;
36
+ chainId: number;
37
+ owner: string;
38
+ spender: string;
39
+ value: bigint;
40
+ nonce: number;
41
+ deadline: number;
42
+ },
43
+ ): Promise<[number, string, string]> {
44
+ const domain = {
45
+ name: opts.tokenName,
46
+ version: opts.version,
47
+ chainId: opts.chainId,
48
+ verifyingContract: getAddress(opts.token),
49
+ };
50
+ const types = {
51
+ Permit: [
52
+ { name: "owner", type: "address" },
53
+ { name: "spender", type: "address" },
54
+ { name: "value", type: "uint256" },
55
+ { name: "nonce", type: "uint256" },
56
+ { name: "deadline", type: "uint256" },
57
+ ],
58
+ };
59
+ const message = {
60
+ owner: getAddress(opts.owner),
61
+ spender: getAddress(opts.spender),
62
+ value: opts.value,
63
+ nonce: opts.nonce,
64
+ deadline: opts.deadline,
65
+ };
66
+ const sig = await account.signTypedData(domain, types, message);
67
+ const split = Signature.from(sig);
68
+ return [split.v, split.r, split.s];
69
+ }
70
+
71
+ // --------------------------------------------------------------------------- //
72
+ // Permit2 (AllowanceTransfer — PermitSingle)
73
+ // --------------------------------------------------------------------------- //
74
+ /** Sign a Permit2 `PermitSingle` and return the 65-byte signature (0x-hex). */
75
+ export async function signPermitSingle(
76
+ account: Signer,
77
+ opts: {
78
+ chainId: number;
79
+ token: string;
80
+ spender: string;
81
+ amount: bigint;
82
+ expiration: number | bigint;
83
+ nonce: number | bigint;
84
+ sigDeadline: number | bigint;
85
+ },
86
+ ): Promise<string> {
87
+ const domain = {
88
+ name: "Permit2",
89
+ chainId: opts.chainId,
90
+ verifyingContract: PERMIT2_ADDRESS,
91
+ };
92
+ const types = {
93
+ PermitDetails: [
94
+ { name: "token", type: "address" },
95
+ { name: "amount", type: "uint160" },
96
+ { name: "expiration", type: "uint48" },
97
+ { name: "nonce", type: "uint48" },
98
+ ],
99
+ PermitSingle: [
100
+ { name: "details", type: "PermitDetails" },
101
+ { name: "spender", type: "address" },
102
+ { name: "sigDeadline", type: "uint256" },
103
+ ],
104
+ };
105
+ const message = {
106
+ details: {
107
+ token: getAddress(opts.token),
108
+ amount: opts.amount,
109
+ expiration: opts.expiration,
110
+ nonce: opts.nonce,
111
+ },
112
+ spender: getAddress(opts.spender),
113
+ sigDeadline: opts.sigDeadline,
114
+ };
115
+ return account.signTypedData(domain, types, message);
116
+ }
117
+
118
+ // Minimal Permit2 ABI for allowance reads + approve.
119
+ export const PERMIT2_ABI = [
120
+ {
121
+ name: "allowance", type: "function", stateMutability: "view",
122
+ inputs: [
123
+ { name: "user", type: "address" }, { name: "token", type: "address" },
124
+ { name: "spender", type: "address" },
125
+ ],
126
+ outputs: [
127
+ { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" },
128
+ { name: "nonce", type: "uint48" },
129
+ ],
130
+ },
131
+ {
132
+ name: "approve", type: "function", stateMutability: "nonpayable",
133
+ inputs: [
134
+ { name: "token", type: "address" }, { name: "spender", type: "address" },
135
+ { name: "amount", type: "uint160" }, { name: "expiration", type: "uint48" },
136
+ ],
137
+ outputs: [],
138
+ },
139
+ ] as const;
140
+
141
+ /** Thin helper around the canonical Permit2 contract for advanced flows. */
142
+ export class Permit2 {
143
+ private _c: SallyClient;
144
+ readonly address: string;
145
+ readonly contract: Contract;
146
+
147
+ constructor(client: SallyClient) {
148
+ this._c = client;
149
+ this.address = getAddress(PERMIT2_ADDRESS);
150
+ this.contract = new Contract(this.address, PERMIT2_ABI as any, client.w3);
151
+ }
152
+
153
+ async allowance(owner: string, token: string, spender: string): Promise<[bigint, number, number]> {
154
+ const r = await this.contract.allowance(getAddress(owner), getAddress(token), getAddress(spender));
155
+ return [BigInt(r[0]), Number(r[1]), Number(r[2])];
156
+ }
157
+
158
+ /** On-chain `Permit2.approve` (after the token is approved to Permit2). */
159
+ async approveToken(
160
+ token: string,
161
+ spender: string,
162
+ amount: bigint = MAX_UINT160,
163
+ expiration: bigint = MAX_UINT48,
164
+ tx: Record<string, any> = {},
165
+ ): Promise<any> {
166
+ const fn = this.contract.getFunction("approve");
167
+ return this._c.send(fn, [getAddress(token), getAddress(spender), amount, expiration], tx);
168
+ }
169
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Transaction previews for sign-externally mode.
3
+ *
4
+ * A {@link TxPreview} is what you inspect before signing with your own wallet:
5
+ * the fully-populated **unsigned** transaction, the ABI-decoded function + args,
6
+ * the simulated on-chain result (`eth_call`, decoded per the ABI), the decoded
7
+ * revert reason if it would fail, and the gas estimate. It answers "what happens
8
+ * to my wallet if I broadcast this?" — without holding your key.
9
+ */
10
+
11
+ import type { SwapPlan } from "./safety.js";
12
+
13
+ /** Inspectable, simulated, unsigned transaction. */
14
+ export class TxPreview {
15
+ unsignedTx: Record<string, any>; // sign with your own wallet, then broadcastTransaction
16
+ sender: string;
17
+ to: string;
18
+ function: string; // decoded function name
19
+ args: Record<string, any>; // decoded named arguments
20
+ value: bigint;
21
+ simulatedReturn: any; // decoded eth_call result (null if it reverts)
22
+ revert: string | null; // decoded revert reason if it would revert
23
+ gas: bigint | null; // estimated gas (null if it reverts)
24
+
25
+ constructor(opts: {
26
+ unsignedTx: Record<string, any>;
27
+ sender: string;
28
+ to: string;
29
+ function: string;
30
+ args?: Record<string, any>;
31
+ value?: bigint;
32
+ simulatedReturn?: any;
33
+ revert?: string | null;
34
+ gas?: bigint | null;
35
+ }) {
36
+ this.unsignedTx = opts.unsignedTx;
37
+ this.sender = opts.sender;
38
+ this.to = opts.to;
39
+ this.function = opts.function;
40
+ this.args = opts.args ?? {};
41
+ this.value = opts.value ?? 0n;
42
+ this.simulatedReturn = opts.simulatedReturn ?? null;
43
+ this.revert = opts.revert ?? null;
44
+ this.gas = opts.gas ?? null;
45
+ }
46
+
47
+ get willRevert(): boolean {
48
+ return this.revert !== null;
49
+ }
50
+
51
+ summary(): string {
52
+ if (this.willRevert) {
53
+ return `TxPreview(${this.function} -> WOULD REVERT: ${this.revert})`;
54
+ }
55
+ return (
56
+ `TxPreview(${this.function} on ${this.to.slice(0, 10)}… ` +
57
+ `returns=${this.simulatedReturn} gas~${this.gas} value=${this.value})`
58
+ );
59
+ }
60
+ }
61
+
62
+ /**
63
+ * A swap prepared for external signing.
64
+ *
65
+ * Inspect `plan` to see the wallet effect (expected output, min received, price
66
+ * impact, honeypot/tax screen), then sign + broadcast the unsigned txs yourself —
67
+ * `approveTx` first (if `needsApproval`), then `swapTx`.
68
+ */
69
+ export class SwapBuild {
70
+ plan: SwapPlan;
71
+ swapTx: Record<string, any>; // unsigned executeHybridSwap
72
+ approveTx: Record<string, any> | null; // unsigned ERC-20 approve (if needed)
73
+ needsApproval: boolean;
74
+
75
+ constructor(opts: {
76
+ plan: SwapPlan;
77
+ swapTx: Record<string, any>;
78
+ approveTx?: Record<string, any> | null;
79
+ needsApproval?: boolean;
80
+ }) {
81
+ this.plan = opts.plan;
82
+ this.swapTx = opts.swapTx;
83
+ this.approveTx = opts.approveTx ?? null;
84
+ this.needsApproval = opts.needsApproval ?? false;
85
+ }
86
+
87
+ get isSafe(): boolean {
88
+ return this.plan?.isSafe ?? false;
89
+ }
90
+
91
+ summary(): string {
92
+ const appr = this.needsApproval ? " +approve" : "";
93
+ return `SwapBuild(${this.plan.summary()}${appr})`;
94
+ }
95
+ }
package/src/safety.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Swap-safety configuration and the inspectable {@link SwapPlan}.
3
+ *
4
+ * The SDK never lets funds move on a route it has not (a) integrity-checked and
5
+ * (b) — when possible — simulated end-to-end. {@link SwapPlan} is the artifact of
6
+ * that work: the chosen route, every candidate it beat, the realized output from
7
+ * an on-chain simulation, the slippage floor, price-impact and the honeypot/tax
8
+ * probe. `execute` builds a plan, enforces {@link SafetyConfig}, sends, then
9
+ * asserts the received balance actually grew by `minOut` (trust the chain).
10
+ */
11
+
12
+ import type { SwapPath, TokenInfo } from "./types.js";
13
+
14
+ // Defaults aligned with mainstream SDKs (Uniswap interface / 1inch).
15
+ export const DEFAULT_SLIPPAGE_BPS = 50; // 0.5%
16
+ export const DEFAULT_DEADLINE_SECS = 1200; // 20 min
17
+ export const DEFAULT_PROBE_VALUE = 10n ** 16n; // 0.01 native, for the getTokenInfos honeypot probe
18
+
19
+ /** Tunable guard rails. Pass one to `new SallyClient(..., { safety })` or per call. */
20
+ export class SafetyConfig {
21
+ readonly slippageBps: number;
22
+ readonly deadlineSecs: number;
23
+ // price impact (bps of expected-at-marginal-price)
24
+ readonly priceImpactWarnBps: number;
25
+ readonly priceImpactBlockBps: number;
26
+ // transfer tax (bps), measured by the getTokenInfos round-trip probe
27
+ readonly taxWarnBps: number;
28
+ readonly taxBlockBps: number;
29
+ // toggles
30
+ readonly checkIntegrity: boolean;
31
+ readonly checkHoneypot: boolean;
32
+ readonly simulate: boolean;
33
+ readonly balanceDeltaAssert: boolean;
34
+ readonly probeValue: bigint;
35
+ // approval: exact-amount by default (safer than infinite approve)
36
+ readonly unlimitedApproval: boolean;
37
+
38
+ constructor(opts: Partial<SafetyConfigFields> = {}) {
39
+ this.slippageBps = opts.slippageBps ?? DEFAULT_SLIPPAGE_BPS;
40
+ this.deadlineSecs = opts.deadlineSecs ?? DEFAULT_DEADLINE_SECS;
41
+ this.priceImpactWarnBps = opts.priceImpactWarnBps ?? 500; // 5% -> warning
42
+ this.priceImpactBlockBps = opts.priceImpactBlockBps ?? 1500; // 15% -> hard block
43
+ this.taxWarnBps = opts.taxWarnBps ?? 1000; // 10%
44
+ this.taxBlockBps = opts.taxBlockBps ?? 5000; // 50%
45
+ this.checkIntegrity = opts.checkIntegrity ?? true;
46
+ this.checkHoneypot = opts.checkHoneypot ?? true;
47
+ this.simulate = opts.simulate ?? true;
48
+ this.balanceDeltaAssert = opts.balanceDeltaAssert ?? true;
49
+ this.probeValue = opts.probeValue ?? DEFAULT_PROBE_VALUE;
50
+ this.unlimitedApproval = opts.unlimitedApproval ?? false;
51
+ }
52
+
53
+ /** Return a copy with some fields overridden (mirrors dataclasses.replace). */
54
+ replace(opts: Partial<SafetyConfigFields>): SafetyConfig {
55
+ return new SafetyConfig({ ...this.fields(), ...opts });
56
+ }
57
+
58
+ private fields(): SafetyConfigFields {
59
+ return {
60
+ slippageBps: this.slippageBps,
61
+ deadlineSecs: this.deadlineSecs,
62
+ priceImpactWarnBps: this.priceImpactWarnBps,
63
+ priceImpactBlockBps: this.priceImpactBlockBps,
64
+ taxWarnBps: this.taxWarnBps,
65
+ taxBlockBps: this.taxBlockBps,
66
+ checkIntegrity: this.checkIntegrity,
67
+ checkHoneypot: this.checkHoneypot,
68
+ simulate: this.simulate,
69
+ balanceDeltaAssert: this.balanceDeltaAssert,
70
+ probeValue: this.probeValue,
71
+ unlimitedApproval: this.unlimitedApproval,
72
+ };
73
+ }
74
+ }
75
+
76
+ export interface SafetyConfigFields {
77
+ slippageBps: number;
78
+ deadlineSecs: number;
79
+ priceImpactWarnBps: number;
80
+ priceImpactBlockBps: number;
81
+ taxWarnBps: number;
82
+ taxBlockBps: number;
83
+ checkIntegrity: boolean;
84
+ checkHoneypot: boolean;
85
+ simulate: boolean;
86
+ balanceDeltaAssert: boolean;
87
+ probeValue: bigint;
88
+ unlimitedApproval: boolean;
89
+ }
90
+
91
+ /** One quoted route plus its simulated realized output (null if it reverted). */
92
+ export class RouteCandidate {
93
+ label: string;
94
+ route: SwapPath;
95
+ quotedOut: bigint;
96
+ simulatedOut: bigint | null;
97
+ integrityProblems: string[];
98
+
99
+ constructor(label: string, route: SwapPath, quotedOut: bigint, simulatedOut: bigint | null = null, integrityProblems: string[] = []) {
100
+ this.label = label;
101
+ this.route = route;
102
+ this.quotedOut = quotedOut;
103
+ this.simulatedOut = simulatedOut;
104
+ this.integrityProblems = integrityProblems;
105
+ }
106
+
107
+ get usable(): boolean {
108
+ return this.integrityProblems.length === 0 && (this.simulatedOut === null || this.simulatedOut > 0n);
109
+ }
110
+
111
+ /** Ranking key: realized output if simulated, else the quote. */
112
+ get score(): bigint {
113
+ return this.simulatedOut !== null ? this.simulatedOut : this.quotedOut;
114
+ }
115
+ }
116
+
117
+ /** A fully-vetted swap, ready to execute (or inspect/reject). */
118
+ export class SwapPlan {
119
+ constructor(
120
+ public tokenIn: string,
121
+ public tokenOut: string,
122
+ public amountIn: bigint,
123
+ public route: SwapPath,
124
+ public quotedOut: bigint,
125
+ public simulatedOut: bigint | null,
126
+ public minOut: bigint,
127
+ public priceImpactBps: number | null,
128
+ public tokenSafety: TokenInfo | null,
129
+ public candidates: RouteCandidate[],
130
+ public warnings: string[] = [],
131
+ public blockReasons: string[] = [],
132
+ ) {}
133
+
134
+ get isSafe(): boolean {
135
+ return this.blockReasons.length === 0;
136
+ }
137
+
138
+ /** Best available estimate of output (simulated if present, else quoted). */
139
+ get expectedOut(): bigint {
140
+ return this.simulatedOut !== null ? this.simulatedOut : this.quotedOut;
141
+ }
142
+
143
+ summary(): string {
144
+ const sim = this.simulatedOut !== null ? `${this.simulatedOut}` : "n/a";
145
+ return (
146
+ `SwapPlan(${this.amountIn} ${this.tokenIn.slice(0, 8)}… -> ${this.tokenOut.slice(0, 8)}… ` +
147
+ `out~${this.expectedOut} sim=${sim} min=${this.minOut} ` +
148
+ `impact=${this.priceImpactBps}bps steps=${this.route.stepCount} ` +
149
+ `${this.isSafe ? "SAFE" : "BLOCKED:" + this.blockReasons.join(",")})`
150
+ );
151
+ }
152
+ }
package/src/token.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * ERC-20 helpers: metadata, balances, approvals and human <-> raw amounts.
3
+ *
4
+ * Anything with non-18 decimals (USDC = 6, WBTC = 8) needs explicit scaling.
5
+ * {@link TokenAmount} carries the decimals with the value so you never multiply
6
+ * by the wrong power.
7
+ */
8
+
9
+ import { Contract, getAddress } from "ethers";
10
+ import type { SallyClient } from "./client.js";
11
+
12
+ // The native gas token sentinel used across DeFi for ETH/BNB.
13
+ export const NATIVE = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
14
+ export const ZERO = "0x0000000000000000000000000000000000000000";
15
+ export const MAX_UINT256 = 2n ** 256n - 1n;
16
+
17
+ // Minimal ERC-20 ABI — enough for reads + approve.
18
+ export const ERC20_ABI = [
19
+ { name: "name", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
20
+ { name: "symbol", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
21
+ { name: "decimals", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint8" }] },
22
+ { name: "totalSupply", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "uint256" }] },
23
+ { name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "a", type: "address" }], outputs: [{ type: "uint256" }] },
24
+ { name: "allowance", type: "function", stateMutability: "view", inputs: [{ name: "o", type: "address" }, { name: "s", type: "address" }], outputs: [{ type: "uint256" }] },
25
+ { name: "approve", type: "function", stateMutability: "nonpayable", inputs: [{ name: "s", type: "address" }, { name: "v", type: "uint256" }], outputs: [{ type: "bool" }] },
26
+ { name: "transfer", type: "function", stateMutability: "nonpayable", inputs: [{ name: "t", type: "address" }, { name: "v", type: "uint256" }], outputs: [{ type: "bool" }] },
27
+ // EIP-2612
28
+ { name: "nonces", type: "function", stateMutability: "view", inputs: [{ name: "o", type: "address" }], outputs: [{ type: "uint256" }] },
29
+ { name: "DOMAIN_SEPARATOR", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "bytes32" }] },
30
+ { name: "version", type: "function", stateMutability: "view", inputs: [], outputs: [{ type: "string" }] },
31
+ {
32
+ name: "permit", type: "function", stateMutability: "nonpayable", inputs: [
33
+ { name: "owner", type: "address" }, { name: "spender", type: "address" },
34
+ { name: "value", type: "uint256" }, { name: "deadline", type: "uint256" },
35
+ { name: "v", type: "uint8" }, { name: "r", type: "bytes32" }, { name: "s", type: "bytes32" },
36
+ ], outputs: [],
37
+ },
38
+ ] as const;
39
+
40
+ export function isNative(address: string): boolean {
41
+ return address.toLowerCase() === NATIVE.toLowerCase() || address.toLowerCase() === ZERO.toLowerCase();
42
+ }
43
+
44
+ /**
45
+ * Convert a human amount to raw base units. Sub-unit precision is truncated
46
+ * toward zero (e.g. `0.0000001` of a 6-decimal token -> `0` raw), matching
47
+ * Python's `int()` semantics.
48
+ */
49
+ export function humanToRaw(amount: string | number | bigint, decimals: number): bigint {
50
+ if (typeof amount === "bigint") return amount * 10n ** BigInt(decimals);
51
+ let s = typeof amount === "number" ? numberToPlainString(amount) : String(amount).trim();
52
+ let neg = false;
53
+ if (s.startsWith("-")) {
54
+ neg = true;
55
+ s = s.slice(1);
56
+ } else if (s.startsWith("+")) {
57
+ s = s.slice(1);
58
+ }
59
+ let [intPart, fracPart = ""] = s.split(".");
60
+ if (intPart === "") intPart = "0";
61
+ // Truncate toward zero: keep at most `decimals` fractional digits.
62
+ fracPart = fracPart.slice(0, decimals).padEnd(decimals, "0");
63
+ const combined = `${intPart}${fracPart}`.replace(/^0+(?=\d)/, "");
64
+ let result = BigInt(combined || "0");
65
+ if (neg) result = -result;
66
+ return result;
67
+ }
68
+
69
+ /** Render `number` without scientific notation so BigInt parsing is exact. */
70
+ function numberToPlainString(n: number): string {
71
+ if (Number.isInteger(n)) return n.toFixed(0);
72
+ const s = String(n);
73
+ if (!/e/i.test(s)) return s;
74
+ // Expand exponent notation (e.g. 1e-7) into plain decimal.
75
+ const [mantissa, expRaw] = s.split(/e/i);
76
+ const exp = Number(expRaw);
77
+ const sign = mantissa.startsWith("-") ? "-" : "";
78
+ const digits = mantissa.replace("-", "").replace(".", "");
79
+ const pointIdx = (mantissa.replace("-", "").indexOf(".") === -1)
80
+ ? mantissa.replace("-", "").length
81
+ : mantissa.replace("-", "").indexOf(".");
82
+ let newPoint = pointIdx + exp;
83
+ if (newPoint <= 0) {
84
+ return `${sign}0.${"0".repeat(-newPoint)}${digits}`;
85
+ }
86
+ if (newPoint >= digits.length) {
87
+ return `${sign}${digits}${"0".repeat(newPoint - digits.length)}`;
88
+ }
89
+ return `${sign}${digits.slice(0, newPoint)}.${digits.slice(newPoint)}`;
90
+ }
91
+
92
+ /** Convert raw base units back to a human float. */
93
+ export function rawToHuman(raw: bigint, decimals: number): number {
94
+ return Number(raw) / 10 ** decimals;
95
+ }
96
+
97
+ /**
98
+ * A token amount that knows its own decimals.
99
+ *
100
+ * `TokenAmount.fromHuman("1.5", 6).raw` -> 1500000n (1.5 USDC)
101
+ */
102
+ export class TokenAmount {
103
+ constructor(public readonly raw: bigint, public readonly decimals: number) {}
104
+
105
+ static fromHuman(amount: string | number | bigint, decimals: number): TokenAmount {
106
+ return new TokenAmount(humanToRaw(amount, decimals), decimals);
107
+ }
108
+
109
+ get human(): number {
110
+ return rawToHuman(this.raw, this.decimals);
111
+ }
112
+
113
+ toBigInt(): bigint {
114
+ return this.raw;
115
+ }
116
+ }
117
+
118
+ /** A live ERC-20 bound to a client. Metadata is fetched lazily and cached. */
119
+ export class Token {
120
+ readonly client: SallyClient;
121
+ readonly address: string;
122
+ readonly contract: Contract;
123
+ private _decimals: number | null = null;
124
+ private _symbol: string | null = null;
125
+
126
+ constructor(client: SallyClient, address: string) {
127
+ this.client = client;
128
+ this.address = getAddress(address);
129
+ this.contract = new Contract(this.address, ERC20_ABI as any, client.w3);
130
+ }
131
+
132
+ // -- metadata --------------------------------------------------------- //
133
+ async decimals(): Promise<number> {
134
+ if (this._decimals === null) {
135
+ this._decimals = Number(await this.contract.decimals());
136
+ }
137
+ return this._decimals;
138
+ }
139
+
140
+ async symbol(): Promise<string> {
141
+ if (this._symbol === null) {
142
+ try {
143
+ this._symbol = await this.contract.symbol();
144
+ } catch {
145
+ this._symbol = "?";
146
+ }
147
+ }
148
+ return this._symbol!;
149
+ }
150
+
151
+ // -- reads ------------------------------------------------------------ //
152
+ async balanceOf(owner: string): Promise<bigint> {
153
+ return BigInt(await this.contract.balanceOf(getAddress(owner)));
154
+ }
155
+
156
+ async allowance(owner: string, spender: string): Promise<bigint> {
157
+ return BigInt(await this.contract.allowance(getAddress(owner), getAddress(spender)));
158
+ }
159
+
160
+ async toRaw(human: string | number | bigint): Promise<bigint> {
161
+ return TokenAmount.fromHuman(human, await this.decimals()).raw;
162
+ }
163
+
164
+ async toHuman(raw: bigint): Promise<number> {
165
+ return rawToHuman(raw, await this.decimals());
166
+ }
167
+
168
+ // -- writes ----------------------------------------------------------- //
169
+ /** Approve `spender` (defaults to unlimited). Returns a receipt. */
170
+ async approve(spender: string, amount: bigint = MAX_UINT256, tx: Record<string, any> = {}): Promise<any> {
171
+ const fn = this.contract.getFunction("approve");
172
+ return this.client.send(fn, [getAddress(spender), amount], tx);
173
+ }
174
+
175
+ /** Unsigned ERC-20 approve tx (for sign-externally mode). */
176
+ async buildApprove(spender: string, amount: bigint = MAX_UINT256): Promise<Record<string, any>> {
177
+ const fn = this.contract.getFunction("approve");
178
+ return this.client.send(fn, [getAddress(spender), amount], { simulate: false, buildOnly: true });
179
+ }
180
+
181
+ /**
182
+ * Approve only if the current allowance is insufficient (idempotent).
183
+ *
184
+ * Approves exactly `amount` (pass `MAX_UINT256` for unlimited) — never silently
185
+ * escalates to infinite.
186
+ */
187
+ async ensureAllowance(spender: string, amount: bigint, tx: Record<string, any> = {}): Promise<any> {
188
+ const owner = this.client.requireAddress();
189
+ if ((await this.allowance(owner, spender)) >= amount) {
190
+ return null;
191
+ }
192
+ return this.approve(spender, amount, tx);
193
+ }
194
+
195
+ // -- EIP-2612 permit -------------------------------------------------- //
196
+ /** True if the token exposes EIP-2612 `nonces` + `DOMAIN_SEPARATOR`. */
197
+ async supportsPermit(): Promise<boolean> {
198
+ try {
199
+ await this.contract.nonces(ZERO);
200
+ await this.contract.DOMAIN_SEPARATOR();
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ }
206
+
207
+ private async _permitVersion(): Promise<string> {
208
+ try {
209
+ return String(await this.contract.version());
210
+ } catch {
211
+ return "1";
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Set an allowance via an EIP-2612 signature (one `permit` tx, no approve).
217
+ *
218
+ * `value` is required (pass `MAX_UINT256` explicitly for unlimited) so an
219
+ * infinite allowance is never granted by accident. Requires the client's signer
220
+ * to own the tokens. Returns the receipt.
221
+ */
222
+ async permit(
223
+ spender: string,
224
+ value: bigint,
225
+ opts: { deadline?: number; tx?: Record<string, any> } = {},
226
+ ): Promise<any> {
227
+ const { signPermit2612 } = await import("./permit2.js");
228
+
229
+ const owner = this.client.requireAddress();
230
+ const account = this.client.account;
231
+ if (account === null) throw new Error("permit requires a signer");
232
+ spender = getAddress(spender);
233
+ let deadline = opts.deadline;
234
+ if (deadline === undefined) {
235
+ const block = await this.client.w3.getBlock("latest");
236
+ deadline = Number(block!.timestamp) + 1200;
237
+ }
238
+ const nonce = Number(await this.contract.nonces(owner));
239
+ const name = await this.contract.name();
240
+ const [v, r, s] = await signPermit2612(account, {
241
+ token: this.address,
242
+ tokenName: name,
243
+ version: await this._permitVersion(),
244
+ chainId: this.client.chainId,
245
+ owner,
246
+ spender,
247
+ value,
248
+ nonce,
249
+ deadline,
250
+ });
251
+ const fn = this.contract.getFunction("permit");
252
+ return this.client.send(fn, [owner, spender, value, deadline, v, r, s], opts.tx ?? {});
253
+ }
254
+ }