@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.
- package/package.json +1 -1
- package/scaffold-patches/packages/app-core/src/api/automations-compat-routes.ts +924 -0
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/package.json +1 -1
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/__tests__/routes/estimate-routes.test.ts +5 -2
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/app.js +896 -19
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/index.html +280 -94
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/style.css +969 -235
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/keys-routes.ts +170 -0
- package/templates-manifest.json +1 -1
|
@@ -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.
|
|
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)
|
|
727
|
-
$("#kpi-accrued").textContent = fmtPton(accrued)
|
|
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 = "#
|
|
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 =
|
|
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 = "#
|
|
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 = "#
|
|
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 -
|
|
955
|
-
ctx.strokeStyle = "rgba(28,
|
|
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
|
-
|
|
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 = "#
|
|
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
|
-
|
|
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("
|
|
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") {
|