@tokagent/tokagentos 2.0.23 → 2.0.29

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.
@@ -7,7 +7,10 @@
7
7
  // 3. PTON top-up: client builds a TransferWithAuthorization EIP-712, signs
8
8
  // it via the wallet, and submits it as X-PAYMENT on a one-call dummy
9
9
  // /v1/messages POST so the proxy executes vault.depositX402.
10
- // 4. Usage tabs (overview / day / model / key / calls) backed by the
10
+ // 4. Swap-to-PTON (UI shell only, v2.0.21): user picks USDC/USDT/ETH/WBTC,
11
+ // preview shows route + PTON output, CTA wires to a stub `swapToPton()`
12
+ // that the next engineer will fill in (approve → swap → wrap → deposit).
13
+ // 5. Usage tabs (overview / day / model / key / calls) backed by the
11
14
  // /v1/usage/* endpoints.
12
15
  //
13
16
  // The file intentionally keeps a single shared `state` object so view-render
@@ -36,6 +39,36 @@ const CHAIN_EXPLORER_URL = String(CONFIG.CHAIN_EXPLORER_URL ?? "");
36
39
  const SESSION_KEY = "ai-proxy-dashboard:session";
37
40
  const ATTO = 10n ** 18n;
38
41
 
42
+ // ---- Swap UI catalog (used by Swap card; addresses are placeholders the
43
+ // engineer will replace when wiring on-chain calls). Icons come from the
44
+ // public CoinGecko CDN so we don't need a build pipeline for sprite assets.
45
+ const SWAP_TOKENS = [
46
+ {
47
+ symbol: "USDC",
48
+ name: "USD Coin",
49
+ decimals: 6,
50
+ icon: "https://assets.coingecko.com/coins/images/6319/small/usdc.png",
51
+ },
52
+ {
53
+ symbol: "USDT",
54
+ name: "Tether",
55
+ decimals: 6,
56
+ icon: "https://assets.coingecko.com/coins/images/325/small/Tether.png",
57
+ },
58
+ {
59
+ symbol: "ETH",
60
+ name: "Ether",
61
+ decimals: 18,
62
+ icon: "https://assets.coingecko.com/coins/images/279/small/ethereum.png",
63
+ },
64
+ {
65
+ symbol: "WBTC",
66
+ name: "Wrapped BTC",
67
+ decimals: 8,
68
+ icon: "https://assets.coingecko.com/coins/images/7598/small/wrapped_bitcoin_wbtc.png",
69
+ },
70
+ ];
71
+
39
72
  const state = {
40
73
  /** EIP-1193 provider (window.ethereum). */
41
74
  provider: null,
@@ -53,6 +86,17 @@ const state = {
53
86
  walletEth: null,
54
87
  /** Connected wallet's PTON token balance (atto, BigInt). */
55
88
  walletPton: null,
89
+ /** Per-token wallet balances for the Swap card. Keyed by symbol (USDC, USDT,
90
+ * ETH, WBTC). Engineer will populate via on-chain reads when wiring swap.
91
+ * Format: float (display units, NOT raw atto). */
92
+ walletBalances: {},
93
+ /** Per-token USD prices for the Swap output preview. Placeholders until the
94
+ * engineer wires a real price feed (Pyth / Chainlink / 1inch quote). */
95
+ tokenPrices: { USDC: 1, USDT: 1, ETH: 3000, WBTC: 65000 },
96
+ /** Currently-selected input token for the Swap card. */
97
+ swapInputToken: "USDC",
98
+ /** Slippage tolerance, basis points. 50 = 0.5%. */
99
+ swapSlippageBps: 50,
56
100
  /** Most recent /v1/usage/summary response. */
57
101
  usage: null,
58
102
  /** Pagination cursor for the "Recent calls" tab. */
@@ -623,6 +667,444 @@ async function topUp(ptonFloat) {
623
667
  };
624
668
  }
625
669
 
670
+ // ----------------------------- Swap → PTON pipeline -----------------------------
671
+ //
672
+ // User flow: pick USDC / USDT / ETH / WBTC, click Swap, watch 3-4 wallet pops
673
+ // fire in order, end up with vault credits.
674
+ //
675
+ // Route (verified on-chain 2026-05-20):
676
+ //
677
+ // ERC-20 path: input ──Uniswap V3──▶ WETH ──Uniswap V3──▶ WTON
678
+ // (fee per token) (0.3% pool, 0xC29271…)
679
+ // └──▶ WTON.swapToTON ▶ TON (1 WTON_ray = 1e-9 TON_wei)
680
+ // └──▶ TON.approve(PTON) ▶ PTON.deposit ▶ PTON
681
+ // └──▶ EIP-3009 ▶ vault.depositX402
682
+ //
683
+ // ETH path: SwapRouter02 with tokenIn=WETH, msg.value=amountIn (router auto-wraps),
684
+ // then identical WTON→TON→PTON→vault tail.
685
+ //
686
+ // Mainnet (chainId 1) contracts:
687
+ // SwapRouter02 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45
688
+ // QuoterV2 0x61fFE014bA17989E743c5F6cB21bF9697530B21e
689
+ // WETH 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
690
+ // WTON (ray=27d) 0xc4A11aaf6ea915Ed7Ac194161d2fC9384F15bff2
691
+ // TON (18d) 0x2be5e8c109e2197D077D13A82dAead6a9b3433C5
692
+ // PTON (18d) 0x00D1EDcE8E7c617891FF76224DFf501c568f1Ce0 (PTON.ton() == TON above)
693
+ //
694
+ // Decimal contract: WTON uses 27-decimal "ray". TON / PTON use 18-decimal "wei".
695
+ // WTON.swapToTON(wtonRay) burns wtonRay from caller and mints (wtonRay / 1e9) TON_wei.
696
+
697
+ const SWAP_ADDRESSES = {
698
+ USDC: { address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals: 6, weth_fee: 500 /* 0.05% */ },
699
+ USDT: { address: "0xdAC17F958D2ee523a2206206994597C13D831ec7", decimals: 6, weth_fee: 500 },
700
+ WBTC: { address: "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", decimals: 8, weth_fee: 3000 /* 0.3% */ },
701
+ ETH: { address: null, decimals: 18, weth_fee: null /* native */ },
702
+ };
703
+ const SWAP_WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
704
+ const SWAP_WTON = "0xc4A11aaf6ea915Ed7Ac194161d2fC9384F15bff2";
705
+ const SWAP_TON = "0x2be5e8c109e2197D077D13A82dAead6a9b3433C5";
706
+ const SWAP_ROUTER02 = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
707
+ const SWAP_QUOTER_V2 = "0x61fFE014bA17989E743c5F6cB21bF9697530B21e";
708
+ const SWAP_WTON_FEE = 3000; // WETH/WTON pool fee
709
+ const WTON_RAY_PER_WEI = 10n ** 9n; // WTON is 27d, TON is 18d → ratio 1e9
710
+
711
+ // ---------------------- tiny ABI encoding helpers (no deps) ----------------------
712
+
713
+ // Strip 0x, lowercase. Returns "" for falsy.
714
+ function _stripHex(h) {
715
+ if (!h) return "";
716
+ return String(h).replace(/^0x/i, "").toLowerCase();
717
+ }
718
+ // Left-pad to 32 bytes (64 hex chars).
719
+ function _pad32(hex) {
720
+ const clean = _stripHex(hex);
721
+ if (clean.length > 64) throw new Error(`_pad32: value too large (${clean.length} hex chars)`);
722
+ return clean.padStart(64, "0");
723
+ }
724
+ // uint256 → 32-byte hex (no 0x).
725
+ function _encUint(n) {
726
+ const big = typeof n === "bigint" ? n : BigInt(n);
727
+ if (big < 0n) throw new Error("_encUint: negative");
728
+ return _pad32(big.toString(16));
729
+ }
730
+ // address → 32-byte hex (no 0x).
731
+ function _encAddr(addr) {
732
+ return _pad32(_stripHex(addr));
733
+ }
734
+ // 4-byte selector via keccak256 over the canonical signature.
735
+ // We don't have keccak in vanilla JS — but every selector we need is
736
+ // well-known, so we hard-code them as constants (audited against 4byte.directory).
737
+ const SEL_ERC20_APPROVE = "095ea7b3"; // approve(address,uint256)
738
+ const SEL_ERC20_ALLOWANCE = "dd62ed3e"; // allowance(address,address)
739
+ const SEL_ERC20_BALANCE_OF = "70a08231"; // balanceOf(address)
740
+ const SEL_PTON_DEPOSIT = "b6b55f25"; // deposit(uint256)
741
+ const SEL_WTON_SWAP_TO_TON = "f53fe70f"; // swapToTON(uint256)
742
+ const SEL_ROUTER_EXACT_IN = "b858183f"; // exactInput((bytes,address,uint256,uint256))
743
+ const SEL_QUOTER_EXACT_IN = "cdca1753"; // quoteExactInput(bytes,uint256)
744
+
745
+ // parseUnits — convert a JS Number/string to BigInt atto units of given decimals,
746
+ // without floating-point drift for sane decimal strings.
747
+ function parseUnits(amount, decimals) {
748
+ const s = String(amount).trim();
749
+ if (!/^\d+(\.\d+)?$/.test(s)) throw new Error(`parseUnits: bad amount "${amount}"`);
750
+ const [wholeStr, fracStr = ""] = s.split(".");
751
+ const fracPadded = (fracStr + "0".repeat(decimals)).slice(0, decimals);
752
+ const combined = (wholeStr === "0" ? "" : wholeStr) + fracPadded;
753
+ const cleaned = combined.replace(/^0+/, "") || "0";
754
+ return BigInt(cleaned);
755
+ }
756
+
757
+ // formatUnits — BigInt → display string with `decimals` precision, trimmed.
758
+ function formatUnits(value, decimals, displayDigits = 6) {
759
+ const big = typeof value === "bigint" ? value : BigInt(value);
760
+ const neg = big < 0n;
761
+ const abs = neg ? -big : big;
762
+ const denom = 10n ** BigInt(decimals);
763
+ const whole = abs / denom;
764
+ const frac = abs % denom;
765
+ const fracStr = frac.toString().padStart(decimals, "0").slice(0, displayDigits);
766
+ const trimmed = fracStr.replace(/0+$/, "");
767
+ const body = trimmed.length === 0 ? whole.toString() : `${whole.toString()}.${trimmed}`;
768
+ return neg ? "-" + body : body;
769
+ }
770
+
771
+ // Build the Uniswap V3 path bytes: addr | fee(3) | addr | fee(3) | addr...
772
+ // Returns 0x-prefixed hex string suitable as a `bytes` arg.
773
+ function _buildV3Path(hops) {
774
+ // hops = [{ token: addr }, { fee, token }, { fee, token }, ...]
775
+ if (!Array.isArray(hops) || hops.length < 2) throw new Error("path needs >= 2 hops");
776
+ let out = _stripHex(hops[0].token);
777
+ for (let i = 1; i < hops.length; i++) {
778
+ const h = hops[i];
779
+ if (typeof h.fee !== "number") throw new Error(`hop ${i} missing fee`);
780
+ out += h.fee.toString(16).padStart(6, "0"); // uint24 = 3 bytes = 6 hex
781
+ out += _stripHex(h.token);
782
+ }
783
+ return "0x" + out;
784
+ }
785
+
786
+ // Encode `bytes` ABI param. Returns the dynamic offset+length+padded content
787
+ // suitable for splicing into a calldata payload. The CALLER manages the
788
+ // containing header (offsets).
789
+ function _encBytes(hex) {
790
+ const clean = _stripHex(hex);
791
+ const lenHex = _encUint(BigInt(clean.length / 2));
792
+ // pad to 32-byte boundary
793
+ const padded = clean + "0".repeat((64 - (clean.length % 64)) % 64);
794
+ return { len: lenHex, content: padded };
795
+ }
796
+
797
+ // Encode a call to QuoterV2.quoteExactInput(bytes path, uint256 amountIn).
798
+ // QuoterV2 returns (uint256 amountOut, uint160[] sqrtPriceX96AfterList,
799
+ // uint32[] initializedTicksCrossedList, uint256 gasEstimate).
800
+ // We only need amountOut — the first 32 bytes of the return data.
801
+ function _encQuoteExactInput(path, amountIn) {
802
+ // Layout (after 4-byte selector):
803
+ // word 0: offset to `bytes path` = 0x40 (64 = two words ahead)
804
+ // word 1: amountIn
805
+ // word 2: bytes length
806
+ // word 3+: padded bytes
807
+ const b = _encBytes(path);
808
+ const data = SEL_QUOTER_EXACT_IN
809
+ + _encUint(64n)
810
+ + _encUint(amountIn)
811
+ + b.len
812
+ + b.content;
813
+ return "0x" + data;
814
+ }
815
+
816
+ // Encode SwapRouter02.exactInput((bytes path, address recipient,
817
+ // uint256 amountIn, uint256 amountOutMinimum)).
818
+ // The struct is dynamic because it contains `bytes`, so the outer arg is also
819
+ // passed by offset. Layout:
820
+ // word 0: offset to struct = 0x20
821
+ // word 1..3: head of struct (offset to bytes, recipient, amountIn, amountOutMinimum)
822
+ // word 4+: bytes payload (length + content padded)
823
+ function _encExactInput({ path, recipient, amountIn, amountOutMinimum }) {
824
+ const b = _encBytes(path);
825
+ // Inside the struct: 4 words (bytes offset, recipient, amountIn, amountOutMin).
826
+ // Bytes offset (within struct) = 0x80 (4 * 32).
827
+ const data = SEL_ROUTER_EXACT_IN
828
+ + _encUint(32n) // offset to struct
829
+ + _encUint(128n) // struct.bytes offset
830
+ + _encAddr(recipient)
831
+ + _encUint(amountIn)
832
+ + _encUint(amountOutMinimum)
833
+ + b.len
834
+ + b.content;
835
+ return "0x" + data;
836
+ }
837
+
838
+ // ---------------------- swap pipeline plumbing ----------------------
839
+
840
+ // Read ERC-20 allowance(owner, spender). Returns BigInt.
841
+ async function _readAllowance(token, owner, spender) {
842
+ const data = "0x" + SEL_ERC20_ALLOWANCE + _encAddr(owner) + _encAddr(spender);
843
+ const hex = await rpc("eth_call", [{ to: token, data }, "latest"]);
844
+ return BigInt(hex);
845
+ }
846
+
847
+ // Read ERC-20 balanceOf(owner). Returns BigInt.
848
+ async function _readErc20Balance(token, owner) {
849
+ const data = "0x" + SEL_ERC20_BALANCE_OF + _encAddr(owner);
850
+ const hex = await rpc("eth_call", [{ to: token, data }, "latest"]);
851
+ return BigInt(hex);
852
+ }
853
+
854
+ // Quote: amountIn of inputToken → WTON (in ray). For ERC-20 we go
855
+ // input→WETH→WTON. For ETH we go WETH→WTON directly (since the router pulls
856
+ // the ETH wrap from msg.value when tokenIn=WETH).
857
+ async function _quoteSwapToWton(inputToken, amountIn) {
858
+ let hops;
859
+ if (inputToken === "ETH") {
860
+ hops = [{ token: SWAP_WETH }, { fee: SWAP_WTON_FEE, token: SWAP_WTON }];
861
+ } else {
862
+ const cfg = SWAP_ADDRESSES[inputToken];
863
+ hops = [
864
+ { token: cfg.address },
865
+ { fee: cfg.weth_fee, token: SWAP_WETH },
866
+ { fee: SWAP_WTON_FEE, token: SWAP_WTON },
867
+ ];
868
+ }
869
+ const path = _buildV3Path(hops);
870
+ const data = _encQuoteExactInput(path, amountIn);
871
+ // QuoterV2 mutates storage with simulated swaps, so it MUST be invoked via
872
+ // eth_call — never sent as a tx. (It's safe via eth_call because state mutation
873
+ // inside eth_call is discarded.)
874
+ const hex = await rpc("eth_call", [{ to: SWAP_QUOTER_V2, data }, "latest"]);
875
+ // First 32 bytes of the return = amountOut (uint256 of WTON in ray).
876
+ if (!hex || hex === "0x") throw new Error("quoter returned empty data");
877
+ const amountOutWtonRay = BigInt("0x" + _stripHex(hex).slice(0, 64));
878
+ return { path, amountOutWtonRay };
879
+ }
880
+
881
+ // Submit `data` from the user's wallet to `to` (optional `value`) and poll for
882
+ // receipt. Throws if the receipt reports failure or 90s timeout elapses.
883
+ async function _sendAndWait({ to, data, value }) {
884
+ const from = await activeAccountOrThrow();
885
+ const txParams = { from, to, data };
886
+ if (value !== undefined && value > 0n) txParams.value = "0x" + value.toString(16);
887
+ const txHash = await rpc("eth_sendTransaction", [txParams]);
888
+ const deadline = Date.now() + 90_000;
889
+ while (Date.now() < deadline) {
890
+ const rcpt = await rpc("eth_getTransactionReceipt", [txHash]);
891
+ if (rcpt) {
892
+ if (rcpt.status === "0x1") return { txHash, rcpt };
893
+ throw new Error(`tx ${txHash} reverted on-chain`);
894
+ }
895
+ await new Promise((r) => setTimeout(r, 1500));
896
+ }
897
+ throw new Error(`tx ${txHash} receipt timeout after 90s`);
898
+ }
899
+
900
+ // Ensure `spender` has at least `amount` allowance of `token` from the user.
901
+ // Handles the USDT quirk: USDT (and a handful of other tokens) revert any
902
+ // approve() call where the current allowance is non-zero and the new allowance
903
+ // is also non-zero. The fix is the standard "approve(0) then approve(amount)"
904
+ // dance. We do that unconditionally for USDT to keep the code simple.
905
+ async function _ensureAllowance({ token, owner, spender, amount, symbol }) {
906
+ const current = await _readAllowance(token, owner, spender);
907
+ if (current >= amount) return; // already enough — skip
908
+ const isUsdt = symbol === "USDT";
909
+ if (isUsdt && current > 0n) {
910
+ setStatus($("#swap-status"), `Resetting USDT allowance to 0…`);
911
+ const data0 = "0x" + SEL_ERC20_APPROVE + _encAddr(spender) + _encUint(0n);
912
+ await _sendAndWait({ to: token, data: data0 });
913
+ }
914
+ setStatus($("#swap-status"), `Approving ${symbol}…`);
915
+ const data = "0x" + SEL_ERC20_APPROVE + _encAddr(spender) + _encUint(amount);
916
+ await _sendAndWait({ to: token, data });
917
+ }
918
+
919
+ // Execute the full swap → wrap → vault pipeline.
920
+ async function swapToPton({ inputToken, inputAmountFloat, slippageBps }) {
921
+ // ---- 0. validate inputs / wallet / chain --------------------------------
922
+ if (!inputToken || !SWAP_ADDRESSES[inputToken]) {
923
+ throw new Error(`Unsupported input token: ${inputToken}`);
924
+ }
925
+ if (!Number.isFinite(inputAmountFloat) || inputAmountFloat <= 0) {
926
+ throw new Error("Amount must be > 0");
927
+ }
928
+ if (!Number.isFinite(slippageBps) || slippageBps < 0 || slippageBps > 10_000) {
929
+ throw new Error("Slippage must be 0..10000 bps");
930
+ }
931
+ if (!state.provider || !state.wallet) {
932
+ throw new Error("Connect a wallet first.");
933
+ }
934
+ if (CHAIN_ID !== 1) {
935
+ throw new Error(`Swap is only supported on Ethereum mainnet (chainId=1); current=${CHAIN_ID}.`);
936
+ }
937
+ const user = await activeAccountOrThrow();
938
+ await ensureChain();
939
+
940
+ const cfg = SWAP_ADDRESSES[inputToken];
941
+ const amountIn = parseUnits(inputAmountFloat, cfg.decimals);
942
+ setStatus($("#swap-status"), `Quoting ${inputToken} → WTON…`);
943
+
944
+ // ---- 1. quote via QuoterV2 → compute amountOutMinimum -------------------
945
+ // QuoterV2 returns the expected WTON-ray output. We apply the user's
946
+ // slippage tolerance to derive the minOut we'll pass to the router.
947
+ const { path, amountOutWtonRay } = await _quoteSwapToWton(inputToken, amountIn);
948
+ if (amountOutWtonRay === 0n) {
949
+ throw new Error("Quote returned 0 — no liquidity on this route.");
950
+ }
951
+ const minOutWtonRay =
952
+ (amountOutWtonRay * BigInt(10_000 - slippageBps)) / 10_000n;
953
+
954
+ // The minimum TON we're guaranteed to be able to wrap from the swap.
955
+ // We use the realized WTON balance delta post-swap (see step 3) as the
956
+ // canonical wrap amount — that's safe against unrelated WTON dust the user
957
+ // might already hold because we snapshot the balance *before* the swap.
958
+ // This early sanity check just rejects quotes so small they'd round to 0.
959
+ const minOutTonWei = minOutWtonRay / WTON_RAY_PER_WEI;
960
+ if (minOutTonWei === 0n) {
961
+ throw new Error("Quote too small (rounds to 0 TON). Increase amount.");
962
+ }
963
+
964
+ // Refresh the visible preview now that we have a real quote (overrides
965
+ // the placeholder USD-price estimate the UI showed pre-click).
966
+ try {
967
+ const outEl = document.getElementById("swap-output-pton");
968
+ if (outEl) outEl.textContent = formatUnits(minOutTonWei, 18, 6);
969
+ } catch { /* presentational only */ }
970
+
971
+ // ---- 2. stuck-flow recovery check ---------------------------------------
972
+ // If the user already has WTON in their wallet (>= the amount this swap
973
+ // would deliver), reuse it instead of doing another swap. This happens
974
+ // when a previous attempt completed step 3 (swap) but failed somewhere
975
+ // in step 4-6 (unwrap → deposit → vault) — the WTON ended up stranded
976
+ // in the wallet. The simplest, gas-cheapest recovery is to skip the
977
+ // fresh swap entirely and consume the existing WTON.
978
+ const wtonBalanceBefore = await _readErc20Balance(SWAP_WTON, user);
979
+ let wtonReceived; // amount we consider "ours" from this flow
980
+ if (wtonBalanceBefore >= minOutWtonRay) {
981
+ setStatus(
982
+ $("#swap-status"),
983
+ `Found ${formatUnits(wtonBalanceBefore / WTON_RAY_PER_WEI, 18, 4)} WTON from a previous attempt — reusing it (skipping swap).`,
984
+ );
985
+ wtonReceived = wtonBalanceBefore;
986
+ } else {
987
+ // ---- 3a. approve router (ERC-20 only) ---------------------------------
988
+ if (inputToken !== "ETH") {
989
+ await _ensureAllowance({
990
+ token: cfg.address,
991
+ owner: user,
992
+ spender: SWAP_ROUTER02,
993
+ amount: amountIn,
994
+ symbol: inputToken,
995
+ });
996
+ }
997
+ // ---- 3b. swap via SwapRouter02.exactInput → recipient = user ----------
998
+ // For ETH input the router auto-wraps msg.value when path starts with WETH.
999
+ // For ERC-20 the router uses the allowance we just set.
1000
+ setStatus(
1001
+ $("#swap-status"),
1002
+ inputToken === "ETH"
1003
+ ? `Swapping ETH → WTON via Uniswap V3…`
1004
+ : `Swapping ${inputToken} → WETH → WTON via Uniswap V3…`,
1005
+ );
1006
+ const swapData = _encExactInput({
1007
+ path,
1008
+ recipient: user,
1009
+ amountIn,
1010
+ amountOutMinimum: minOutWtonRay,
1011
+ });
1012
+ await _sendAndWait({
1013
+ to: SWAP_ROUTER02,
1014
+ data: swapData,
1015
+ value: inputToken === "ETH" ? amountIn : 0n,
1016
+ });
1017
+ // Use the realized post-swap WTON balance delta as the canonical amount
1018
+ // for wrap → vault — this guarantees we don't over-wrap (which would
1019
+ // revert in PTON.deposit on insufficient TON balance) and forwards the
1020
+ // full swap output, not just the minimum.
1021
+ const wtonBalanceAfter = await _readErc20Balance(SWAP_WTON, user);
1022
+ wtonReceived = wtonBalanceAfter - wtonBalanceBefore;
1023
+ if (wtonReceived < minOutWtonRay) {
1024
+ // The receipt said 0x1 but no WTON delta. Most likely the wallet
1025
+ // returned a stale tx hash (e.g. dedup of an identical previous
1026
+ // calldata) and we polled the OLD receipt. If the wallet's TOTAL
1027
+ // WTON balance now exceeds minOut, treat that as the swap output
1028
+ // (it must have come from somewhere — and the user paid for it).
1029
+ if (wtonBalanceAfter >= minOutWtonRay) {
1030
+ setStatus(
1031
+ $("#swap-status"),
1032
+ `Swap tx returned no new WTON, but wallet has ${formatUnits(wtonBalanceAfter / WTON_RAY_PER_WEI, 18, 4)} WTON — using that.`,
1033
+ );
1034
+ wtonReceived = wtonBalanceAfter;
1035
+ } else {
1036
+ throw new Error(
1037
+ `Swap underdelivered: got ${wtonReceived} WTON-ray, expected >= ${minOutWtonRay}`,
1038
+ );
1039
+ }
1040
+ }
1041
+ }
1042
+ // Convert ray→wei. Truncate any sub-1e9-ray dust (will sit in wallet as WTON).
1043
+ const tonToWrap = wtonReceived / WTON_RAY_PER_WEI;
1044
+ if (tonToWrap === 0n) {
1045
+ throw new Error("Swap produced sub-wei TON dust — increase amount.");
1046
+ }
1047
+ const wtonToBurn = tonToWrap * WTON_RAY_PER_WEI;
1048
+
1049
+ // ---- 4. WTON.swapToTON(wtonToBurn) → user's TON balance ----------------
1050
+ setStatus($("#swap-status"), `Unwrapping WTON → TON…`);
1051
+ {
1052
+ const data = "0x" + SEL_WTON_SWAP_TO_TON + _encUint(wtonToBurn);
1053
+ await _sendAndWait({ to: SWAP_WTON, data });
1054
+ }
1055
+
1056
+ // ---- 5. TON.approve(PTON, tonToWrap) ------------------------------------
1057
+ // Tokamak TON is a plain OZ ERC-20 with no USDT-style approve quirk, so the
1058
+ // single approve + deposit pattern is safe.
1059
+ await _ensureAllowance({
1060
+ token: SWAP_TON,
1061
+ owner: user,
1062
+ spender: state.pton ?? (await resolveDepositTargets()).pton,
1063
+ amount: tonToWrap,
1064
+ symbol: "TON",
1065
+ });
1066
+
1067
+ // ---- 6. PTON.deposit(tonToWrap) → user's PTON balance -------------------
1068
+ setStatus($("#swap-status"), `Wrapping TON → PTON…`);
1069
+ const ptonAddr = state.pton ?? (await resolveDepositTargets()).pton;
1070
+ {
1071
+ const data = "0x" + SEL_PTON_DEPOSIT + _encUint(tonToWrap);
1072
+ await _sendAndWait({ to: ptonAddr, data });
1073
+ }
1074
+
1075
+ // ---- 7. EIP-3009 sign + vault.depositX402 -------------------------------
1076
+ // Reuse the existing topUp() pipeline: it builds the same TransferWithAuth
1077
+ // we'd need (PTON, from=user, to=vault, value=ptonAmount) and POSTs
1078
+ // /v1/topup/settle which drives the vault deposit.
1079
+ setStatus($("#swap-status"), `Crediting vault…`);
1080
+ // topUp() takes a float — convert atto-PTON back to a float. Use truncated
1081
+ // micro-PTON arithmetic to match topUp()'s own atto<->float conversion path
1082
+ // (it does `BigInt(Math.round(f * 1e6)) * (ATTO/1e6)`), so we avoid creating
1083
+ // an atto value with sub-micro precision that topUp() would silently round.
1084
+ const microPton = tonToWrap / (ATTO / 1_000_000n); // 18d → 6d truncation
1085
+ if (microPton === 0n) throw new Error("Deposit too small (sub-micro PTON)");
1086
+ const ptonFloat = Number(microPton) / 1_000_000;
1087
+ await topUp(ptonFloat);
1088
+
1089
+ // ---- 8. UI refresh + reset ---------------------------------------------
1090
+ setStatus(
1091
+ $("#swap-status"),
1092
+ `Done — ${formatUnits(tonToWrap, 18, 6)} PTON credited.`,
1093
+ "ok",
1094
+ );
1095
+ try {
1096
+ const amtEl = document.getElementById("swap-amount");
1097
+ if (amtEl) amtEl.value = "";
1098
+ } catch { /* ignore */ }
1099
+ // Refresh the on-chain numbers. Each loader is allowed to fail (e.g. proxy
1100
+ // briefly unavailable) without blocking the success status above.
1101
+ await Promise.all([
1102
+ loadCredits().catch(() => {}),
1103
+ loadWalletHoldings().catch(() => {}),
1104
+ ]);
1105
+ renderKpis();
1106
+ }
1107
+
626
1108
  // ----------------------------- Loaders -----------------------------
627
1109
 
628
1110
  async function loadPrice() {
@@ -651,6 +1133,9 @@ async function loadWalletHoldings() {
651
1133
  try {
652
1134
  const ethHex = await rpc("eth_getBalance", [state.wallet, "latest"]);
653
1135
  state.walletEth = BigInt(ethHex);
1136
+ // Mirror native ETH into the per-token swap balance map for the "Max"
1137
+ // button on the Swap card. ETH wallet balance is in wei (1e18).
1138
+ state.walletBalances.ETH = Number(state.walletEth) / 1e18;
654
1139
  } catch (e) {
655
1140
  state.walletEth = null;
656
1141
  console.warn("eth_getBalance failed", e);
@@ -662,10 +1147,34 @@ async function loadWalletHoldings() {
662
1147
  const data = "0x70a08231" + padded;
663
1148
  const ptonHex = await rpc("eth_call", [{ to: state.pton, data }, "latest"]);
664
1149
  state.walletPton = BigInt(ptonHex);
1150
+ // Mirror PTON into the swap-card balance map too — handy so a user who
1151
+ // already holds PTON in their wallet can see it next to USDC/etc., and
1152
+ // for forward compat if we ever add PTON as a swap input (no-op route).
1153
+ state.walletBalances.PTON = Number(state.walletPton) / 1e18;
665
1154
  } catch (e) {
666
1155
  state.walletPton = null;
667
1156
  console.warn("PTON.balanceOf failed", e);
668
1157
  }
1158
+ // Pull the Swap-card ERC-20 balances (USDC / USDT / WBTC) so the "Max"
1159
+ // button works and the balance hint isn't perpetually "— USDC". These calls
1160
+ // are mainnet-only (the token addresses are mainnet constants); on any
1161
+ // other chain we silently leave the entries undefined so the UI shows "—".
1162
+ if (CHAIN_ID === 1) {
1163
+ for (const sym of ["USDC", "USDT", "WBTC"]) {
1164
+ const cfg = SWAP_ADDRESSES[sym];
1165
+ try {
1166
+ const padded = state.wallet.toLowerCase().replace(/^0x/, "").padStart(64, "0");
1167
+ const data = "0x70a08231" + padded;
1168
+ const hex = await rpc("eth_call", [{ to: cfg.address, data }, "latest"]);
1169
+ const raw = BigInt(hex || "0x0");
1170
+ state.walletBalances[sym] = Number(raw) / 10 ** cfg.decimals;
1171
+ } catch (e) {
1172
+ // Swallow per-token RPC errors so one flaky read can't blank the
1173
+ // whole holdings card. Leaving the entry undefined falls back to "—".
1174
+ console.warn(`${sym}.balanceOf failed`, e);
1175
+ }
1176
+ }
1177
+ }
669
1178
  }
670
1179
 
671
1180
  async function loadUsage() {
@@ -723,8 +1232,8 @@ function renderKpis() {
723
1232
  const reserved = c?.ledger?.reserved ?? c?.reserved ?? 0n;
724
1233
  const accrued = c?.ledger?.accrued ?? c?.accrued ?? 0n;
725
1234
  $("#kpi-balance").textContent = fmtPton(balance);
726
- $("#kpi-reserved").textContent = fmtPton(reserved) + " PTON";
727
- $("#kpi-accrued").textContent = fmtPton(accrued) + " PTON";
1235
+ $("#kpi-reserved").textContent = fmtPton(reserved);
1236
+ $("#kpi-accrued").textContent = fmtPton(accrued);
728
1237
  $("#kpi-balance-usd").textContent = fmtUsdFromAttoPton(balance, state.tonUsd);
729
1238
  // Wallet holdings (outside the vault). ETH and PTON share 18 decimals so
730
1239
  // fmtPton works for both — only the unit label differs.
@@ -739,7 +1248,10 @@ function renderKpis() {
739
1248
  $("#kpi-calls").textContent = fmtNumber(u?.calls ?? 0);
740
1249
  $("#kpi-calls-ok").textContent = fmtNumber(u?.successCalls ?? 0);
741
1250
  $("#kpi-calls-fail").textContent = fmtNumber(u?.failedCalls ?? 0);
742
- $("#usage-retention").textContent = String(u?.retentionDays ?? "");
1251
+ $("#usage-retention").textContent = String(u?.retentionDays ?? "90");
1252
+
1253
+ // Sync swap card balance hint whenever wallet balances refresh.
1254
+ renderSwapPreview();
743
1255
  }
744
1256
 
745
1257
  function renderUsageOverview() {
@@ -857,8 +1369,8 @@ async function renderKeysTable() {
857
1369
  const tr = document.createElement("tr");
858
1370
  const status = k.revokedAt ? `<span class="outcome-pill outcome-failed">revoked</span>` : `<span class="outcome-pill outcome-success">active</span>`;
859
1371
  const action = k.revokedAt
860
- ? `<button class="btn btn-danger" type="button" data-delete="${escape(k.id)}" title="Permanently remove this revoked key from the database">Delete</button>`
861
- : `<button class="btn btn-danger" type="button" data-revoke="${escape(k.id)}">Revoke</button>`;
1372
+ ? `<button class="btn btn-danger btn-small" type="button" data-delete="${escape(k.id)}" title="Permanently remove this revoked key from the database">Delete</button>`
1373
+ : `<button class="btn btn-danger btn-small" type="button" data-revoke="${escape(k.id)}">Revoke</button>`;
862
1374
  // Note: Delete (hard-delete) is intentionally NOT shown next to Revoke
863
1375
  // for active keys — Revoke first, then Delete the revoked row. Avoids
864
1376
  // accidentally wiping an active key (which would lose all in-flight
@@ -925,18 +1437,18 @@ function drawBarChart(canvasId, data) {
925
1437
  ctx.clearRect(0, 0, w, h);
926
1438
 
927
1439
  if (!data.length) {
928
- ctx.fillStyle = "#94a3c1";
1440
+ ctx.fillStyle = "#9ca3af";
929
1441
  ctx.font = "12px Inter, sans-serif";
930
1442
  ctx.textAlign = "center";
931
1443
  ctx.fillText("No data in window.", w / 2, h / 2);
932
1444
  return;
933
1445
  }
934
- const padL = 32, padR = 16, padT = 16, padB = 28;
1446
+ const padL = 36, padR = 16, padT = 16, padB = 28;
935
1447
  const max = Math.max(...data.map((d) => d.value), 1);
936
1448
  const barW = (w - padL - padR) / data.length;
937
1449
 
938
1450
  // Axes
939
- ctx.strokeStyle = "#1c2742";
1451
+ ctx.strokeStyle = "#1c1c2a";
940
1452
  ctx.lineWidth = 1;
941
1453
  ctx.beginPath();
942
1454
  ctx.moveTo(padL, padT);
@@ -945,14 +1457,14 @@ function drawBarChart(canvasId, data) {
945
1457
  ctx.stroke();
946
1458
 
947
1459
  // Y grid + labels (3 horizontal lines)
948
- ctx.fillStyle = "#94a3c1";
949
- ctx.font = "10px JetBrains Mono, monospace";
1460
+ ctx.fillStyle = "#6b7280";
1461
+ ctx.font = "10px 'JetBrains Mono', monospace";
950
1462
  ctx.textAlign = "right";
951
1463
  for (let i = 0; i <= 3; i++) {
952
1464
  const y = padT + ((h - padT - padB) * (3 - i)) / 3;
953
1465
  const v = Math.round((max * i) / 3);
954
- ctx.fillText(String(v), padL - 4, y + 3);
955
- ctx.strokeStyle = "rgba(28,39,66,0.6)";
1466
+ ctx.fillText(String(v), padL - 6, y + 3);
1467
+ ctx.strokeStyle = "rgba(28,28,42,0.7)";
956
1468
  ctx.beginPath();
957
1469
  ctx.moveTo(padL, y);
958
1470
  ctx.lineTo(w - padR, y);
@@ -963,7 +1475,7 @@ function drawBarChart(canvasId, data) {
963
1475
  for (let i = 0; i < data.length; i++) {
964
1476
  const d = data[i];
965
1477
  const x = padL + i * barW + 2;
966
- const innerW = barW - 4;
1478
+ const innerW = Math.max(2, barW - 4);
967
1479
  const barH = ((h - padT - padB) * d.value) / max;
968
1480
  const y = h - padB - barH;
969
1481
  // Match the parent app's lime accent. Read the computed --accent so
@@ -973,11 +1485,25 @@ function drawBarChart(canvasId, data) {
973
1485
  ? getComputedStyle(document.documentElement).getPropertyValue("--accent").trim()
974
1486
  : "") || "#c4f547";
975
1487
  ctx.fillStyle = accent;
976
- ctx.fillRect(x, y, innerW, barH);
1488
+ // Subtle rounded-top bars
1489
+ const r = Math.min(3, innerW / 2);
1490
+ if (barH > r * 2) {
1491
+ ctx.beginPath();
1492
+ ctx.moveTo(x, y + barH);
1493
+ ctx.lineTo(x, y + r);
1494
+ ctx.quadraticCurveTo(x, y, x + r, y);
1495
+ ctx.lineTo(x + innerW - r, y);
1496
+ ctx.quadraticCurveTo(x + innerW, y, x + innerW, y + r);
1497
+ ctx.lineTo(x + innerW, y + barH);
1498
+ ctx.closePath();
1499
+ ctx.fill();
1500
+ } else {
1501
+ ctx.fillRect(x, y, innerW, barH);
1502
+ }
977
1503
  }
978
1504
 
979
1505
  // X labels (truncate to fit)
980
- ctx.fillStyle = "#94a3c1";
1506
+ ctx.fillStyle = "#9ca3af";
981
1507
  ctx.font = "10px Inter, sans-serif";
982
1508
  ctx.textAlign = "center";
983
1509
  const everyN = Math.max(1, Math.ceil(data.length / 10));
@@ -1044,11 +1570,13 @@ function wireTabs() {
1044
1570
  for (const tab of document.querySelectorAll(".tab")) {
1045
1571
  tab.addEventListener("click", () => {
1046
1572
  document.querySelectorAll(".tab").forEach((t) => {
1047
- t.classList.remove("active");
1573
+ // is-active is the new class; .active is preserved for any legacy
1574
+ // CSS reader (the stylesheet keys off both).
1575
+ t.classList.remove("is-active", "active");
1048
1576
  t.setAttribute("aria-selected", "false");
1049
1577
  });
1050
1578
  document.querySelectorAll(".tab-pane").forEach((p) => (p.hidden = true));
1051
- tab.classList.add("active");
1579
+ tab.classList.add("is-active", "active");
1052
1580
  tab.setAttribute("aria-selected", "true");
1053
1581
  const name = tab.getAttribute("data-tab");
1054
1582
  state.activeTab = name;
@@ -1062,7 +1590,7 @@ function wireTabs() {
1062
1590
  }
1063
1591
 
1064
1592
  function wireTopupPresets() {
1065
- for (const b of document.querySelectorAll(".topup-presets button")) {
1593
+ for (const b of document.querySelectorAll("#topup-preset-row .chip")) {
1066
1594
  b.addEventListener("click", () => {
1067
1595
  $("#topup-amount").value = b.getAttribute("data-preset");
1068
1596
  updateTopupUsd();
@@ -1073,6 +1601,8 @@ function wireTopupPresets() {
1073
1601
 
1074
1602
  function updateTopupUsd() {
1075
1603
  const v = parseFloat($("#topup-amount").value);
1604
+ const estEl = $("#topup-est-pton");
1605
+ if (estEl) estEl.textContent = Number.isFinite(v) && v > 0 ? String(v) : "0";
1076
1606
  if (!Number.isFinite(v) || v <= 0 || !state.tonUsd) {
1077
1607
  $("#topup-usd").textContent = "—";
1078
1608
  return;
@@ -1112,6 +1642,114 @@ function wireKeyCreate() {
1112
1642
  setStatus($("#key-modal-copy-status"), "Copy failed — select the value above and copy manually.", "err");
1113
1643
  }
1114
1644
  });
1645
+
1646
+ // ── "Install & Restart Agent" — open the confirm modal ────────────────
1647
+ $("#key-modal-install").addEventListener("click", () => {
1648
+ // Reset any prior status text in the confirm modal so the user starts
1649
+ // from a clean slate if they bounce in and out of the confirm dialog.
1650
+ setStatus($("#key-install-status"), "");
1651
+ $("#key-install-modal").hidden = false;
1652
+ $("#key-install-confirm").disabled = false;
1653
+ $("#key-install-cancel").disabled = false;
1654
+ });
1655
+ $("#key-install-cancel").addEventListener("click", () => {
1656
+ $("#key-install-modal").hidden = true;
1657
+ });
1658
+ $("#key-install-confirm").addEventListener("click", async () => {
1659
+ const installStatus = $("#key-install-status");
1660
+ const confirmBtn = $("#key-install-confirm");
1661
+ const cancelBtn = $("#key-install-cancel");
1662
+ const key = $("#key-modal-value").textContent?.trim() ?? "";
1663
+ if (!/^sk-ai-[A-Za-z0-9_-]{16,}$/.test(key)) {
1664
+ setStatus(installStatus, "Key text is not a valid sk-ai-... value.", "err");
1665
+ return;
1666
+ }
1667
+ confirmBtn.disabled = true;
1668
+ cancelBtn.disabled = true;
1669
+ setStatus(installStatus, "Writing key to .env…");
1670
+ try {
1671
+ // Step 1: write the .env. CRITICAL: this MUST hit the local agent,
1672
+ // not the remote billing gateway — the gateway has no access to the
1673
+ // user's local filesystem. We use a same-origin fetch (no PROXY_BASE
1674
+ // prefix) so it lands on the local agent's /v1/keys/install handler.
1675
+ // (apiJson() prefixes PROXY_BASE which in client-mode = Railway URL.)
1676
+ const installRes = await fetch("/v1/keys/install", {
1677
+ method: "POST",
1678
+ headers: { "content-type": "application/json" },
1679
+ body: JSON.stringify({ key }),
1680
+ });
1681
+ if (!installRes.ok) {
1682
+ let body = null;
1683
+ try { body = await installRes.json(); } catch {}
1684
+ throw new Error(
1685
+ body?.error || `HTTP ${installRes.status} from /v1/keys/install`,
1686
+ );
1687
+ }
1688
+ setStatus(installStatus, "Key saved. Requesting agent restart…");
1689
+ // Step 2: trigger the restart via the existing /api/restart endpoint.
1690
+ // That endpoint handles dev (in-process runtime bounce via
1691
+ // setRestartHandler) and prod (process.exit + supervisor respawn)
1692
+ // correctly; we don't reimplement that strategy here.
1693
+ // /api/restart returns BEFORE the runtime is torn down (1s setTimeout),
1694
+ // so this request itself succeeds. The poll below catches the moment
1695
+ // the runtime is back up.
1696
+ const restartRes = await fetch("/api/restart", {
1697
+ method: "POST",
1698
+ headers: { "content-type": "application/json" },
1699
+ });
1700
+ if (!restartRes.ok) {
1701
+ throw new Error(
1702
+ `/api/restart returned HTTP ${restartRes.status} — key was saved but agent did not restart. Try restarting manually.`,
1703
+ );
1704
+ }
1705
+ setStatus(installStatus, "Agent restarting — waiting for it to come back…");
1706
+ // Poll until /v1/price answers 200 again. The restart cycle is
1707
+ // typically a few seconds; we allow up to 60s for slow rebuilds.
1708
+ const restored = await waitForServerBack({ timeoutMs: 60_000 });
1709
+ if (restored) {
1710
+ setStatus(installStatus, "Agent is back online — reloading page…", "ok");
1711
+ setTimeout(() => window.location.reload(), 800);
1712
+ } else {
1713
+ setStatus(
1714
+ installStatus,
1715
+ "Agent did not come back online within 60s. Check your terminal — you may need to relaunch it manually.",
1716
+ "err",
1717
+ );
1718
+ cancelBtn.disabled = false;
1719
+ }
1720
+ } catch (err) {
1721
+ setStatus(installStatus, `Install failed: ${err.message}`, "err");
1722
+ confirmBtn.disabled = false;
1723
+ cancelBtn.disabled = false;
1724
+ }
1725
+ });
1726
+ }
1727
+
1728
+ // Poll the agent for availability — used by the install-and-restart flow.
1729
+ // We hit a fast public endpoint and treat ANY 2xx response as "back up".
1730
+ // During restart the fetch first throws (TCP refused), then briefly may
1731
+ // return 503 while the runtime initializes, then settles to 200.
1732
+ async function waitForServerBack({ timeoutMs = 60_000 } = {}) {
1733
+ const deadline = Date.now() + timeoutMs;
1734
+ // Initial settle delay so we don't poll the still-alive pre-exit server.
1735
+ // The server told us restartDelayMs=1500 — add a small safety margin.
1736
+ await new Promise((r) => setTimeout(r, 2_000));
1737
+ while (Date.now() < deadline) {
1738
+ try {
1739
+ // /v1/price is a small, cache-able, unauthenticated endpoint.
1740
+ // Cache-bust with a timestamp param so the browser doesn't serve a
1741
+ // 200 from disk cache while the actual server is still down.
1742
+ const resp = await fetch(`/v1/price?_=${Date.now()}`, {
1743
+ method: "GET",
1744
+ cache: "no-store",
1745
+ });
1746
+ if (resp.ok) return true;
1747
+ } catch {
1748
+ // TCP refused / DNS fail / fetch abort — server still down, keep waiting.
1749
+ }
1750
+ await new Promise((r) => setTimeout(r, 1_000));
1751
+ }
1752
+ return false;
1115
1753
  }
1116
1754
 
1117
1755
  function wireFaucet() {
@@ -1160,6 +1798,240 @@ function wireTopup() {
1160
1798
  });
1161
1799
  }
1162
1800
 
1801
+ // ----------------------------- Swap card wiring -----------------------------
1802
+
1803
+ function getSelectedSwapToken() {
1804
+ return SWAP_TOKENS.find((t) => t.symbol === state.swapInputToken) ?? SWAP_TOKENS[0];
1805
+ }
1806
+
1807
+ function renderSwapTokenDisplay() {
1808
+ const tok = getSelectedSwapToken();
1809
+ const icon = document.getElementById("swap-token-icon");
1810
+ const label = document.getElementById("swap-token-label");
1811
+ if (icon) {
1812
+ icon.src = tok.icon;
1813
+ icon.alt = `${tok.symbol} logo`;
1814
+ }
1815
+ if (label) label.textContent = tok.symbol;
1816
+ }
1817
+
1818
+ function renderSwapTokenMenu() {
1819
+ const menu = document.getElementById("swap-token-menu");
1820
+ if (!menu) return;
1821
+ menu.innerHTML = "";
1822
+ for (const t of SWAP_TOKENS) {
1823
+ const opt = document.createElement("button");
1824
+ opt.type = "button";
1825
+ opt.className = "token-option";
1826
+ opt.setAttribute("role", "option");
1827
+ opt.setAttribute("data-symbol", t.symbol);
1828
+ opt.innerHTML = `
1829
+ <img class="token-icon" src="${escape(t.icon)}" alt="" />
1830
+ <span>${escape(t.symbol)}</span>
1831
+ <span class="token-option-sub">${escape(t.name)}</span>
1832
+ `;
1833
+ opt.addEventListener("click", (ev) => {
1834
+ ev.stopPropagation();
1835
+ state.swapInputToken = t.symbol;
1836
+ renderSwapTokenDisplay();
1837
+ renderSwapRoute();
1838
+ renderSwapPreview();
1839
+ closeSwapTokenMenu();
1840
+ });
1841
+ menu.appendChild(opt);
1842
+ }
1843
+ }
1844
+
1845
+ function openSwapTokenMenu() {
1846
+ const menu = document.getElementById("swap-token-menu");
1847
+ const sel = document.getElementById("swap-token-select");
1848
+ if (!menu || !sel) return;
1849
+ menu.hidden = false;
1850
+ sel.setAttribute("aria-expanded", "true");
1851
+ }
1852
+
1853
+ function closeSwapTokenMenu() {
1854
+ const menu = document.getElementById("swap-token-menu");
1855
+ const sel = document.getElementById("swap-token-select");
1856
+ if (!menu || !sel) return;
1857
+ menu.hidden = true;
1858
+ sel.setAttribute("aria-expanded", "false");
1859
+ }
1860
+
1861
+ function renderSwapRoute() {
1862
+ const route = document.getElementById("swap-route");
1863
+ if (!route) return;
1864
+ const tok = getSelectedSwapToken();
1865
+ // ETH skips the first leg; non-ETH ERC-20 routes via ETH on the way to TON.
1866
+ const steps = tok.symbol === "ETH"
1867
+ ? ["ETH", "TON", "PTON"]
1868
+ : [tok.symbol, "ETH", "TON", "PTON"];
1869
+ route.innerHTML = steps
1870
+ .map((s, i) => {
1871
+ const isEnd = i === steps.length - 1;
1872
+ const stepHtml = `<span class="route-step${isEnd ? " route-step-end" : ""}">${escape(s)}</span>`;
1873
+ return i < steps.length - 1 ? `${stepHtml}<span class="route-arrow">→</span>` : stepHtml;
1874
+ })
1875
+ .join("");
1876
+ }
1877
+
1878
+ function renderSwapPreview() {
1879
+ const tok = getSelectedSwapToken();
1880
+ const amtEl = document.getElementById("swap-amount");
1881
+ const outPton = document.getElementById("swap-output-pton");
1882
+ const outUsd = document.getElementById("swap-output-usd");
1883
+ const balEl = document.getElementById("swap-input-balance");
1884
+ const btn = document.getElementById("swap-btn");
1885
+
1886
+ // Balance hint (the engineer will hydrate state.walletBalances on token
1887
+ // selection / approve; ETH is mirrored from state.walletEth in
1888
+ // loadWalletHoldings()).
1889
+ const bal = state.walletBalances[tok.symbol];
1890
+ if (balEl) {
1891
+ balEl.textContent = (typeof bal === "number" && bal >= 0)
1892
+ ? `${bal.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${tok.symbol}`
1893
+ : `— ${tok.symbol}`;
1894
+ }
1895
+
1896
+ const v = parseFloat(amtEl?.value ?? "");
1897
+ const tokenUsd = state.tokenPrices[tok.symbol] ?? null;
1898
+ const tonUsd = state.tonUsd ?? null;
1899
+
1900
+ // Output preview: inputAmount × tokenUsd / tonUsd. Both prices are
1901
+ // placeholder until the engineer wires a real quote feed.
1902
+ let pton = 0;
1903
+ let usd = 0;
1904
+ if (Number.isFinite(v) && v > 0 && tokenUsd && tonUsd) {
1905
+ usd = v * tokenUsd;
1906
+ pton = usd / tonUsd;
1907
+ }
1908
+ if (outPton) {
1909
+ outPton.textContent = (Number.isFinite(v) && v > 0 && tokenUsd && tonUsd)
1910
+ ? pton.toLocaleString(undefined, { maximumFractionDigits: 6 })
1911
+ : "0.00";
1912
+ }
1913
+ if (outUsd) {
1914
+ outUsd.textContent = (Number.isFinite(v) && v > 0 && tokenUsd)
1915
+ ? usd.toFixed(2)
1916
+ : "—";
1917
+ }
1918
+
1919
+ // CTA state machine: needs wallet, then amount, then enabled.
1920
+ if (btn) {
1921
+ if (!state.session?.wallet) {
1922
+ btn.disabled = true;
1923
+ btn.textContent = "Connect wallet";
1924
+ } else if (!Number.isFinite(v) || v <= 0) {
1925
+ btn.disabled = true;
1926
+ btn.textContent = "Enter an amount";
1927
+ } else {
1928
+ btn.disabled = false;
1929
+ btn.textContent = `Swap ${tok.symbol} to PTON`;
1930
+ }
1931
+ }
1932
+ }
1933
+
1934
+ function wireSwap() {
1935
+ // Token dropdown
1936
+ const sel = document.getElementById("swap-token-select");
1937
+ if (sel) {
1938
+ sel.addEventListener("click", (e) => {
1939
+ e.stopPropagation();
1940
+ const menu = document.getElementById("swap-token-menu");
1941
+ if (!menu) return;
1942
+ if (menu.hidden) openSwapTokenMenu();
1943
+ else closeSwapTokenMenu();
1944
+ });
1945
+ sel.addEventListener("keydown", (e) => {
1946
+ if (e.key === "Enter" || e.key === " ") {
1947
+ e.preventDefault();
1948
+ sel.click();
1949
+ } else if (e.key === "Escape") {
1950
+ closeSwapTokenMenu();
1951
+ }
1952
+ });
1953
+ }
1954
+ document.addEventListener("click", (e) => {
1955
+ const menu = document.getElementById("swap-token-menu");
1956
+ if (!menu || menu.hidden) return;
1957
+ const within = e.target.closest && e.target.closest("#swap-token-select");
1958
+ if (!within) closeSwapTokenMenu();
1959
+ });
1960
+
1961
+ // Amount input
1962
+ const amtEl = document.getElementById("swap-amount");
1963
+ if (amtEl) amtEl.addEventListener("input", renderSwapPreview);
1964
+
1965
+ // Max button
1966
+ const maxBtn = document.getElementById("swap-max-btn");
1967
+ if (maxBtn) {
1968
+ maxBtn.addEventListener("click", (e) => {
1969
+ e.preventDefault();
1970
+ const tok = getSelectedSwapToken();
1971
+ const bal = state.walletBalances[tok.symbol];
1972
+ if (typeof bal === "number" && bal > 0 && amtEl) {
1973
+ amtEl.value = String(bal);
1974
+ renderSwapPreview();
1975
+ } else {
1976
+ setStatus($("#swap-status"), `No ${tok.symbol} balance detected.`, "");
1977
+ }
1978
+ });
1979
+ }
1980
+
1981
+ // Slippage buttons
1982
+ for (const b of document.querySelectorAll(".slippage-btn")) {
1983
+ b.addEventListener("click", () => {
1984
+ document.querySelectorAll(".slippage-btn").forEach((x) => x.classList.remove("is-active"));
1985
+ b.classList.add("is-active");
1986
+ state.swapSlippageBps = Number(b.getAttribute("data-slippage")) || 50;
1987
+ });
1988
+ }
1989
+
1990
+ // CTA — drives the full Swap → wrap → vault pipeline. `swapToPton` sets a
1991
+ // detailed "Done — N PTON credited" status itself before returning, so we
1992
+ // deliberately do NOT overwrite it here on success. On failure, surface the
1993
+ // error to the same status line.
1994
+ const swapBtn = document.getElementById("swap-btn");
1995
+ if (swapBtn) {
1996
+ swapBtn.addEventListener("click", async () => {
1997
+ const status = $("#swap-status");
1998
+ const tok = getSelectedSwapToken();
1999
+ const v = parseFloat(amtEl?.value ?? "");
2000
+ if (!state.session?.wallet) {
2001
+ setStatus(status, "Connect a wallet first.", "err");
2002
+ return;
2003
+ }
2004
+ if (!Number.isFinite(v) || v <= 0) {
2005
+ setStatus(status, "Amount must be > 0", "err");
2006
+ return;
2007
+ }
2008
+ swapBtn.disabled = true;
2009
+ setStatus(status, `Preparing ${tok.symbol} → PTON swap…`);
2010
+ try {
2011
+ await swapToPton({
2012
+ inputToken: tok.symbol,
2013
+ inputAmountFloat: v,
2014
+ slippageBps: state.swapSlippageBps,
2015
+ });
2016
+ // swapToPton already set "Done — N PTON credited" + refreshed credits
2017
+ // and wallet holdings. refreshAll re-fetches usage + price + keys so
2018
+ // the dashboard's other KPIs are also in sync after a long swap flow.
2019
+ await refreshAll();
2020
+ } catch (e) {
2021
+ setStatus(status, e.message, "err");
2022
+ } finally {
2023
+ renderSwapPreview(); // re-evaluate CTA label
2024
+ }
2025
+ });
2026
+ }
2027
+
2028
+ // Initial render
2029
+ renderSwapTokenMenu();
2030
+ renderSwapTokenDisplay();
2031
+ renderSwapRoute();
2032
+ renderSwapPreview();
2033
+ }
2034
+
1163
2035
  function wireCallsPager() {
1164
2036
  $("#calls-load-more").addEventListener("click", async () => {
1165
2037
  if (!state.callsCursor) return;
@@ -1337,6 +2209,7 @@ async function boot() {
1337
2209
  wireKeyCreate();
1338
2210
  wireFaucet();
1339
2211
  wireTopup();
2212
+ wireSwap();
1340
2213
  wireCallsPager();
1341
2214
  wireLogout();
1342
2215
  wireSwitchChain();
@@ -1357,6 +2230,10 @@ async function boot() {
1357
2230
  } else {
1358
2231
  showLoginView();
1359
2232
  }
2233
+ // After every render path the swap CTA should reflect connection state.
2234
+ renderSwapPreview();
2235
+ // Prime the topup USD estimate for the default value.
2236
+ updateTopupUsd();
1360
2237
  }
1361
2238
 
1362
2239
  if (document.readyState === "loading") {