Last active
January 20, 2026 22:17
-
-
Save nambrot/a09469043d9e718ea0ab2b312fc3b0a0 to your computer and use it in GitHub Desktop.
StableSwap Collateral Flow Simulator - Option A Architecture
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>StableSwap Collateral Flow Simulator</title> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: | |
| -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; | |
| background: #0d1117; | |
| color: #c9d1d9; | |
| padding: 20px; | |
| min-height: 100vh; | |
| } | |
| h1 { | |
| color: #58a6ff; | |
| margin-bottom: 8px; | |
| font-size: 1.5rem; | |
| } | |
| .subtitle { | |
| color: #8b949e; | |
| margin-bottom: 24px; | |
| font-size: 0.9rem; | |
| } | |
| .container { | |
| max-width: 1800px; | |
| margin: 0 auto; | |
| } | |
| /* Global Stats */ | |
| .global-stats { | |
| display: flex; | |
| gap: 24px; | |
| margin-bottom: 24px; | |
| padding: 16px; | |
| background: #161b22; | |
| border: 1px solid #30363d; | |
| border-radius: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .stat { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .stat-label { | |
| font-size: 0.75rem; | |
| color: #8b949e; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .stat-value { | |
| font-size: 1.25rem; | |
| font-weight: 600; | |
| color: #58a6ff; | |
| } | |
| .stat-value.success { | |
| color: #3fb950; | |
| } | |
| .stat-value.warning { | |
| color: #d29922; | |
| } | |
| .stat-value.error { | |
| color: #f85149; | |
| } | |
| /* Architecture Diagram */ | |
| .architecture { | |
| position: relative; | |
| background: #161b22; | |
| border: 1px solid #30363d; | |
| border-radius: 8px; | |
| padding: 24px; | |
| margin-bottom: 24px; | |
| } | |
| .arch-title { | |
| font-size: 0.8rem; | |
| color: #8b949e; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| margin-bottom: 20px; | |
| text-align: center; | |
| } | |
| /* Triangle Layout Container */ | |
| .triangle-container { | |
| position: relative; | |
| width: 100%; | |
| height: 750px; | |
| } | |
| /* Chain positioning for triangle */ | |
| .chain-column { | |
| position: absolute; | |
| width: 320px; | |
| } | |
| .chain-column.top { | |
| top: 0; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| } | |
| .chain-column.bottom-left { | |
| bottom: 0; | |
| left: 5%; | |
| } | |
| .chain-column.bottom-right { | |
| bottom: 0; | |
| right: 5%; | |
| } | |
| .chain-header { | |
| text-align: center; | |
| padding: 10px; | |
| background: #0d1117; | |
| border: 2px solid; | |
| border-radius: 8px 8px 0 0; | |
| border-bottom: none; | |
| } | |
| .chain-name { | |
| font-weight: 700; | |
| font-size: 1rem; | |
| } | |
| .chain-domain { | |
| font-size: 0.65rem; | |
| color: #8b949e; | |
| } | |
| .chain-body { | |
| background: #0d1117; | |
| border: 2px solid #30363d; | |
| border-top: none; | |
| border-radius: 0 0 8px 8px; | |
| padding: 10px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| /* Adapters Container */ | |
| .adapters-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .adapters-label { | |
| font-size: 0.6rem; | |
| color: #8b949e; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| /* Adapter Box */ | |
| .adapter-box { | |
| background: #161b22; | |
| border: 1px solid #30363d; | |
| border-radius: 6px; | |
| padding: 8px; | |
| position: relative; | |
| } | |
| .adapter-box.highlight-deposit { | |
| border-color: #58a6ff; | |
| box-shadow: 0 0 10px rgba(88, 166, 255, 0.3); | |
| } | |
| .adapter-box.highlight-redeem { | |
| border-color: #3fb950; | |
| box-shadow: 0 0 10px rgba(63, 185, 80, 0.3); | |
| } | |
| .adapter-box.highlight-rebalance { | |
| border-color: #f97316; | |
| box-shadow: 0 0 10px rgba(249, 115, 22, 0.3); | |
| } | |
| .adapter-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 4px; | |
| } | |
| .adapter-token { | |
| font-weight: 600; | |
| font-size: 0.85rem; | |
| color: #f0f6fc; | |
| } | |
| .adapter-label { | |
| font-size: 0.5rem; | |
| color: #8b949e; | |
| background: #21262d; | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| } | |
| .adapter-stats { | |
| font-size: 0.7rem; | |
| } | |
| .adapter-stat { | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| .adapter-stat-label { | |
| color: #8b949e; | |
| } | |
| .adapter-stat-value { | |
| font-family: monospace; | |
| } | |
| .adapter-stat-value.healthy { | |
| color: #3fb950; | |
| } | |
| .adapter-stat-value.low { | |
| color: #d29922; | |
| } | |
| .adapter-stat-value.depleted { | |
| color: #f85149; | |
| } | |
| .adapter-stat-value.fee { | |
| color: #d29922; | |
| } | |
| /* sUSD Route Box - Separate from adapters */ | |
| .susd-route-container { | |
| margin-top: 8px; | |
| padding-top: 8px; | |
| border-top: 1px dashed #30363d; | |
| } | |
| .susd-route-label { | |
| font-size: 0.6rem; | |
| color: #a371f7; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 6px; | |
| } | |
| .susd-box { | |
| background: rgba(163, 113, 247, 0.15); | |
| border: 2px solid rgba(163, 113, 247, 0.5); | |
| border-radius: 6px; | |
| padding: 10px; | |
| } | |
| .susd-box.highlight { | |
| border-color: #a371f7; | |
| box-shadow: 0 0 12px rgba(163, 113, 247, 0.5); | |
| } | |
| .susd-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .susd-token { | |
| font-weight: 600; | |
| color: #a371f7; | |
| font-size: 0.9rem; | |
| } | |
| .susd-label { | |
| font-size: 0.5rem; | |
| color: #a371f7; | |
| background: rgba(163, 113, 247, 0.25); | |
| padding: 2px 4px; | |
| border-radius: 3px; | |
| } | |
| .susd-supply { | |
| font-family: monospace; | |
| color: #a371f7; | |
| font-size: 0.8rem; | |
| text-align: right; | |
| margin-top: 4px; | |
| } | |
| /* User boxes */ | |
| .user-box { | |
| position: absolute; | |
| background: rgba(236, 72, 153, 0.15); | |
| border: 2px solid rgba(236, 72, 153, 0.6); | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| text-align: center; | |
| min-width: 80px; | |
| box-shadow: 0 0 15px rgba(236, 72, 153, 0.3); | |
| z-index: 10; | |
| } | |
| .user-box.sender { | |
| border-color: #ec4899; | |
| box-shadow: 0 0 15px rgba(236, 72, 153, 0.5); | |
| } | |
| .user-box.receiver { | |
| border-color: #10b981; | |
| background: rgba(16, 185, 129, 0.15); | |
| box-shadow: 0 0 15px rgba(16, 185, 129, 0.5); | |
| } | |
| .user-icon { | |
| font-size: 1.2rem; | |
| margin-bottom: 2px; | |
| } | |
| .user-label { | |
| font-size: 0.65rem; | |
| color: #ec4899; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .user-box.receiver .user-label { | |
| color: #10b981; | |
| } | |
| .user-amount { | |
| font-family: monospace; | |
| font-size: 0.75rem; | |
| color: #f0f6fc; | |
| margin-top: 2px; | |
| } | |
| /* SVG Connections */ | |
| .connections-svg { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| } | |
| /* Legend */ | |
| .legend { | |
| display: flex; | |
| justify-content: center; | |
| gap: 20px; | |
| margin-top: 16px; | |
| padding-top: 12px; | |
| border-top: 1px solid #30363d; | |
| flex-wrap: wrap; | |
| } | |
| .legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 0.65rem; | |
| color: #8b949e; | |
| } | |
| .legend-line { | |
| width: 24px; | |
| height: 3px; | |
| border-radius: 2px; | |
| } | |
| .legend-line.deposit { | |
| background: #58a6ff; | |
| } | |
| .legend-line.warp { | |
| background: #a371f7; | |
| } | |
| .legend-line.redeem { | |
| background: #3fb950; | |
| } | |
| .legend-line.rebalance { | |
| background: repeating-linear-gradient( | |
| 90deg, | |
| #f97316, | |
| #f97316 4px, | |
| transparent 4px, | |
| transparent 8px | |
| ); | |
| } | |
| .legend-line.swap { | |
| background: linear-gradient(90deg, #58a6ff, #3fb950); | |
| } | |
| /* Presets */ | |
| .presets { | |
| margin-bottom: 24px; | |
| } | |
| .presets-label { | |
| font-size: 0.8rem; | |
| color: #8b949e; | |
| margin-bottom: 8px; | |
| } | |
| .preset-buttons { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| } | |
| .preset-btn { | |
| background: #21262d; | |
| color: #c9d1d9; | |
| border: 1px solid #30363d; | |
| padding: 6px 12px; | |
| font-size: 0.8rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| } | |
| .preset-btn:hover { | |
| background: #30363d; | |
| } | |
| .preset-btn:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| } | |
| .preset-btn:disabled:hover { | |
| background: #21262d; | |
| } | |
| /* Actions Panel */ | |
| .actions-panel { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 16px; | |
| margin-bottom: 24px; | |
| } | |
| @media (max-width: 1200px) { | |
| .actions-panel { | |
| grid-template-columns: 1fr 1fr; | |
| } | |
| } | |
| @media (max-width: 800px) { | |
| .actions-panel { | |
| grid-template-columns: 1fr; | |
| } | |
| } | |
| .action-card { | |
| background: #161b22; | |
| border: 1px solid #30363d; | |
| border-radius: 8px; | |
| padding: 16px; | |
| } | |
| .action-title { | |
| font-weight: 600; | |
| color: #f0f6fc; | |
| margin-bottom: 12px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .action-title .icon { | |
| width: 20px; | |
| height: 20px; | |
| border-radius: 4px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 0.7rem; | |
| } | |
| .action-card.transfer .icon { | |
| background: linear-gradient(135deg, #58a6ff, #a371f7, #3fb950); | |
| } | |
| .action-card.rebalance .icon { | |
| background: #f97316; | |
| } | |
| .action-card.swap .icon { | |
| background: linear-gradient(135deg, #58a6ff, #3fb950); | |
| } | |
| .action-form { | |
| display: grid; | |
| gap: 10px; | |
| } | |
| .form-row { | |
| display: grid; | |
| grid-template-columns: 80px 1fr; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .form-row.amount { | |
| grid-template-columns: 80px 1fr 100px; | |
| } | |
| label { | |
| font-size: 0.8rem; | |
| color: #8b949e; | |
| } | |
| select, | |
| input { | |
| background: #0d1117; | |
| border: 1px solid #30363d; | |
| border-radius: 4px; | |
| padding: 8px 12px; | |
| color: #c9d1d9; | |
| font-size: 0.85rem; | |
| } | |
| select:focus, | |
| input:focus { | |
| outline: none; | |
| border-color: #58a6ff; | |
| } | |
| button { | |
| background: #238636; | |
| color: #fff; | |
| border: none; | |
| border-radius: 4px; | |
| padding: 10px 16px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| } | |
| button:hover { | |
| background: #2ea043; | |
| } | |
| .fee-indicator { | |
| font-size: 0.7rem; | |
| color: #8b949e; | |
| margin-top: 4px; | |
| line-height: 1.4; | |
| } | |
| .flow-description { | |
| font-size: 0.7rem; | |
| color: #58a6ff; | |
| background: rgba(88, 166, 255, 0.1); | |
| padding: 8px; | |
| border-radius: 4px; | |
| margin-top: 8px; | |
| line-height: 1.4; | |
| } | |
| .flow-description.rebalance { | |
| color: #f97316; | |
| background: rgba(249, 115, 22, 0.1); | |
| } | |
| /* Transaction Log */ | |
| .log-panel { | |
| background: #161b22; | |
| border: 1px solid #30363d; | |
| border-radius: 8px; | |
| padding: 16px; | |
| } | |
| .log-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 12px; | |
| } | |
| .log-title { | |
| font-weight: 600; | |
| color: #f0f6fc; | |
| } | |
| .log-entries { | |
| max-height: 200px; | |
| overflow-y: auto; | |
| font-family: monospace; | |
| font-size: 0.75rem; | |
| } | |
| .log-entry { | |
| padding: 6px 8px; | |
| border-radius: 4px; | |
| margin-bottom: 4px; | |
| background: #0d1117; | |
| } | |
| .log-entry.success { | |
| border-left: 3px solid #3fb950; | |
| } | |
| .log-entry.error { | |
| border-left: 3px solid #f85149; | |
| } | |
| .log-entry.info { | |
| border-left: 3px solid #58a6ff; | |
| } | |
| .log-entry.rebalance { | |
| border-left: 3px solid #f97316; | |
| } | |
| .log-timestamp { | |
| color: #8b949e; | |
| margin-right: 8px; | |
| } | |
| .log-message { | |
| color: #c9d1d9; | |
| } | |
| .log-details { | |
| color: #8b949e; | |
| font-size: 0.7rem; | |
| margin-top: 2px; | |
| } | |
| /* Invariant Warning */ | |
| .invariant-warning { | |
| background: #f8514922; | |
| border: 1px solid #f85149; | |
| border-radius: 8px; | |
| padding: 12px; | |
| margin-bottom: 24px; | |
| display: none; | |
| } | |
| .invariant-warning.show { | |
| display: block; | |
| } | |
| /* Arrow animations */ | |
| @keyframes flowForward { | |
| from { | |
| stroke-dashoffset: 16; | |
| } | |
| to { | |
| stroke-dashoffset: 0; | |
| } | |
| } | |
| .animated-path { | |
| animation: flowForward 0.4s linear infinite; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>StableSwap Collateral Flow Simulator</h1> | |
| <p class="subtitle"> | |
| Option A: Per-Adapter Architecture — Triangle topology with separated | |
| sUSD route | |
| </p> | |
| <!-- Presets --> | |
| <div class="presets"> | |
| <div class="presets-label">Load Scenario:</div> | |
| <div class="preset-buttons"> | |
| <button | |
| class="preset-btn" | |
| style="background: #238636; border-color: #238636" | |
| onclick="loadLiveData()" | |
| id="liveDataBtn" | |
| > | |
| Load Live Data | |
| </button> | |
| <button class="preset-btn" onclick="loadPreset('balanced')"> | |
| Balanced Start | |
| </button> | |
| <button class="preset-btn" onclick="loadPreset('imbalanced')"> | |
| Imbalanced (Arb depleted) | |
| </button> | |
| <button class="preset-btn" onclick="loadPreset('appchain')"> | |
| Soneium Bootstrap | |
| </button> | |
| <button class="preset-btn" onclick="resetState()">Reset</button> | |
| </div> | |
| <div | |
| id="liveDataStatus" | |
| style="font-size: 0.75rem; color: #8b949e; margin-top: 8px" | |
| ></div> | |
| </div> | |
| <!-- Invariant Warning --> | |
| <div class="invariant-warning" id="invariantWarning"> | |
| ⚠️ <strong>Invariant Violated:</strong> Total Collateral ≠ Total sUSD | |
| Supply. | |
| </div> | |
| <!-- Global Stats --> | |
| <div class="global-stats"> | |
| <div class="stat"> | |
| <span class="stat-label">Total sUSD Supply</span> | |
| <span class="stat-value" id="totalSUSD">0</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Total Collateral</span> | |
| <span class="stat-value" id="totalCollateral">0</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Invariant</span> | |
| <span class="stat-value success" id="invariantCheck">✓ Valid</span> | |
| </div> | |
| <div class="stat"> | |
| <span class="stat-label">Fees Collected</span> | |
| <span class="stat-value" id="totalFees">0</span> | |
| </div> | |
| </div> | |
| <!-- Architecture Diagram --> | |
| <div class="architecture"> | |
| <div class="arch-title"> | |
| StableSwap Architecture — Collateral Adapters + sUSD Warp Route | |
| </div> | |
| <div class="triangle-container" id="triangleContainer"> | |
| <svg class="connections-svg" id="connectionsSvg"></svg> | |
| </div> | |
| <div class="legend"> | |
| <div class="legend-item"> | |
| <div class="legend-line deposit"></div> | |
| <span>deposit() - adapter→sUSD</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-line warp"></div> | |
| <span>transferRemote() - sUSD cross-chain</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-line redeem"></div> | |
| <span>redeem() - sUSD→adapter</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-line rebalance"></div> | |
| <span>rebalance() - adapter→adapter</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-line swap"></div> | |
| <span>swapLocal() - same-chain swap</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-line" style="background: #ec4899"></div> | |
| <span>user sends tokens</span> | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-line" style="background: #10b981"></div> | |
| <span>user receives tokens</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Actions Panel --> | |
| <div class="actions-panel"> | |
| <!-- Cross-Chain Transfer --> | |
| <div class="action-card transfer"> | |
| <div class="action-title"> | |
| <div class="icon">→</div> | |
| Cross-Chain Transfer | |
| </div> | |
| <div class="action-form"> | |
| <div class="form-row"> | |
| <label>From:</label> | |
| <select | |
| id="transferFromChain" | |
| onchange="updateTransferTokens(); updateFlowDescription();" | |
| ></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>Input:</label> | |
| <select | |
| id="transferInputToken" | |
| onchange="updateFlowDescription();" | |
| ></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>To:</label> | |
| <select | |
| id="transferToChain" | |
| onchange="updateTransferOutputTokens(); updateFlowDescription();" | |
| ></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>Output:</label> | |
| <select | |
| id="transferOutputToken" | |
| onchange="updateFlowDescription();" | |
| ></select> | |
| </div> | |
| <div class="form-row amount"> | |
| <label>Amount:</label> | |
| <input | |
| type="number" | |
| id="transferAmount" | |
| value="100" | |
| min="0" | |
| step="1" | |
| /> | |
| <button onclick="executeTransfer()">Transfer</button> | |
| </div> | |
| <div class="flow-description" id="transferFlowDesc"></div> | |
| <div class="fee-indicator"> | |
| Fee: 5bps on deposit. Collateral increases on origin, decreases on | |
| destination. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Rebalance --> | |
| <div class="action-card rebalance"> | |
| <div class="action-title"> | |
| <div class="icon">⇄</div> | |
| Rebalance Collateral | |
| </div> | |
| <div class="action-form"> | |
| <div class="form-row"> | |
| <label>From:</label> | |
| <select | |
| id="rebalanceFromChain" | |
| onchange="updateRebalanceTokens(); updateRebalanceDesc();" | |
| ></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>Token:</label> | |
| <select | |
| id="rebalanceToken" | |
| onchange="updateRebalanceDesc();" | |
| ></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>To:</label> | |
| <select | |
| id="rebalanceToChain" | |
| onchange="updateRebalanceTokens(); updateRebalanceDesc();" | |
| ></select> | |
| </div> | |
| <div class="form-row amount"> | |
| <label>Amount:</label> | |
| <input | |
| type="number" | |
| id="rebalanceAmount" | |
| value="100" | |
| min="0" | |
| step="1" | |
| /> | |
| <button onclick="executeRebalance()">Rebalance</button> | |
| </div> | |
| <div | |
| class="flow-description rebalance" | |
| id="rebalanceFlowDesc" | |
| ></div> | |
| <div class="fee-indicator"> | |
| Uses underlying warp route. sUSD supply unchanged. Same-token | |
| only. | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Same-Chain Swap --> | |
| <div class="action-card swap"> | |
| <div class="action-title"> | |
| <div class="icon">↔</div> | |
| Same-Chain Swap | |
| </div> | |
| <div class="action-form"> | |
| <div class="form-row"> | |
| <label>Chain:</label> | |
| <select | |
| id="swapChain" | |
| onchange="updateSwapTokens(); updateSwapDesc();" | |
| ></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>From:</label> | |
| <select id="swapFromToken" onchange="updateSwapDesc();"></select> | |
| </div> | |
| <div class="form-row"> | |
| <label>To:</label> | |
| <select id="swapToToken" onchange="updateSwapDesc();"></select> | |
| </div> | |
| <div class="form-row amount"> | |
| <label>Amount:</label> | |
| <input | |
| type="number" | |
| id="swapAmount" | |
| value="100" | |
| min="0" | |
| step="1" | |
| /> | |
| <button onclick="executeSwap()">Swap</button> | |
| </div> | |
| <div | |
| class="flow-description" | |
| style="background: rgba(63, 185, 80, 0.1); color: #3fb950" | |
| id="swapFlowDesc" | |
| ></div> | |
| <div class="fee-indicator"> | |
| Fee: 5bps on deposit. No cross-chain messaging. sUSD supply | |
| unchanged. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Transaction Log --> | |
| <div class="log-panel"> | |
| <div class="log-header"> | |
| <span class="log-title">Transaction Log</span> | |
| <button | |
| style="background: #30363d; padding: 6px 12px; font-size: 0.75rem" | |
| onclick="clearLog()" | |
| > | |
| Clear | |
| </button> | |
| </div> | |
| <div class="log-entries" id="logEntries"></div> | |
| </div> | |
| </div> | |
| <script> | |
| // ============================================ | |
| // CONFIG & STATE | |
| // ============================================ | |
| const FEE_BPS = 5; | |
| // RPC endpoints (public) | |
| const RPC_ENDPOINTS = { | |
| arbitrum: 'https://arb1.arbitrum.io/rpc', | |
| base: 'https://mainnet.base.org', | |
| soneium: 'https://rpc.soneium.org', | |
| }; | |
| // Deployed contract addresses from warp UI config | |
| const DEPLOYED_CONTRACTS = { | |
| arbitrum: { | |
| USDC: { | |
| adapter: '0x94C62e7958738B65737a0Db8A5077def3AED84AA', | |
| collateral: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', | |
| }, | |
| USDT: { | |
| adapter: '0x6BEC839292A36372882Cb850E93FB5aC2A9BA4Af', | |
| collateral: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', | |
| }, | |
| sUSD: '0x38E8720EBE02e7c5254F9De9F81440C7a770a9c6', | |
| helper: '0x880024136413E048427F299FfA85cBbFFc1be2bF', | |
| }, | |
| base: { | |
| USDC: { | |
| adapter: '0x73Ef899fDa87213e26501707ab585028BFB297c8', | |
| collateral: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', | |
| }, | |
| USDT: { | |
| adapter: '0x23c51024b19303F1315DbFFA055666aE9B7A0B2c', | |
| collateral: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', | |
| }, | |
| sUSD: '0x38E8720EBE02e7c5254F9De9F81440C7a770a9c6', | |
| helper: '0xD5d718EE1466A7a7f7e4C3B3bA54636707a919af', | |
| }, | |
| soneium: { | |
| USDSC: { | |
| adapter: '0x867D428B8FbE196EA4e997e7980623E75ED219a7', | |
| collateral: '0x3f99231dD03a9F0E7e3421c92B7b90fbe012985a', | |
| }, | |
| sUSD: '0x38E8720EBE02e7c5254F9De9F81440C7a770a9c6', | |
| helper: '0x73Ef899fDa87213e26501707ab585028BFB297c8', | |
| }, | |
| }; | |
| // ERC20 balanceOf(address) selector | |
| const BALANCE_OF_SELECTOR = '0x70a08231'; | |
| // ERC20 totalSupply() selector | |
| const TOTAL_SUPPLY_SELECTOR = '0x18160ddd'; | |
| const CHAIN_CONFIG = { | |
| base: { | |
| name: 'Base', | |
| domain: 8453, | |
| tokens: ['USDC', 'USDT'], | |
| color: '#0052FF', | |
| position: 'bottom-left', | |
| }, | |
| arbitrum: { | |
| name: 'Arbitrum', | |
| domain: 42161, | |
| tokens: ['USDC', 'USDT'], | |
| color: '#28A0F0', | |
| position: 'bottom-right', | |
| }, | |
| soneium: { | |
| name: 'Soneium', | |
| domain: 1868, | |
| tokens: ['USDSC'], | |
| color: '#9945FF', | |
| position: 'top', | |
| }, | |
| }; | |
| const CHAIN_ORDER = ['base', 'arbitrum', 'soneium']; | |
| // ============================================ | |
| // LIVE DATA FETCHING | |
| // ============================================ | |
| async function ethCall(rpcUrl, to, data) { | |
| const response = await fetch(rpcUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| jsonrpc: '2.0', | |
| method: 'eth_call', | |
| params: [{ to, data }, 'latest'], | |
| id: 1, | |
| }), | |
| }); | |
| const json = await response.json(); | |
| if (json.error) throw new Error(json.error.message); | |
| return json.result; | |
| } | |
| function encodeBalanceOf(address) { | |
| // balanceOf(address) - pad address to 32 bytes | |
| const paddedAddress = address | |
| .toLowerCase() | |
| .replace('0x', '') | |
| .padStart(64, '0'); | |
| return BALANCE_OF_SELECTOR + paddedAddress; | |
| } | |
| function decodeUint256(hex) { | |
| // Convert hex to BigInt, then to number (safe for balances < 2^53) | |
| if (!hex || hex === '0x') return 0; | |
| return Number(BigInt(hex)); | |
| } | |
| async function fetchCollateralBalance(chainId, token) { | |
| const contracts = DEPLOYED_CONTRACTS[chainId]; | |
| if (!contracts || !contracts[token]) return 0; | |
| const rpcUrl = RPC_ENDPOINTS[chainId]; | |
| const adapterAddress = contracts[token].adapter; | |
| const collateralAddress = contracts[token].collateral; | |
| try { | |
| const data = encodeBalanceOf(adapterAddress); | |
| const result = await ethCall(rpcUrl, collateralAddress, data); | |
| // All stablecoins are 6 decimals | |
| return decodeUint256(result) / 1e6; | |
| } catch (err) { | |
| console.error(`Error fetching ${token} balance on ${chainId}:`, err); | |
| return 0; | |
| } | |
| } | |
| async function fetchSUSDSupply(chainId) { | |
| const contracts = DEPLOYED_CONTRACTS[chainId]; | |
| if (!contracts || !contracts.sUSD) return 0; | |
| const rpcUrl = RPC_ENDPOINTS[chainId]; | |
| const sUSDAddress = contracts.sUSD; | |
| try { | |
| const result = await ethCall( | |
| rpcUrl, | |
| sUSDAddress, | |
| TOTAL_SUPPLY_SELECTOR, | |
| ); | |
| // sUSD is 6 decimals (same as underlying stablecoins) | |
| return decodeUint256(result) / 1e6; | |
| } catch (err) { | |
| console.error(`Error fetching sUSD supply on ${chainId}:`, err); | |
| return 0; | |
| } | |
| } | |
| async function loadLiveData() { | |
| const btn = document.getElementById('liveDataBtn'); | |
| const status = document.getElementById('liveDataStatus'); | |
| btn.disabled = true; | |
| btn.textContent = 'Loading...'; | |
| status.textContent = | |
| 'Fetching live data from Arbitrum, Base, and Soneium...'; | |
| status.style.color = '#58a6ff'; | |
| initState(); | |
| try { | |
| // Fetch all data in parallel | |
| const fetchPromises = []; | |
| for (const chainId of CHAIN_ORDER) { | |
| const config = CHAIN_CONFIG[chainId]; | |
| for (const token of config.tokens) { | |
| fetchPromises.push( | |
| fetchCollateralBalance(chainId, token).then((balance) => ({ | |
| chainId, | |
| token, | |
| balance, | |
| })), | |
| ); | |
| } | |
| // Fetch sUSD supply for this chain | |
| fetchPromises.push( | |
| fetchSUSDSupply(chainId).then((supply) => ({ | |
| chainId, | |
| type: 'sUSD', | |
| supply, | |
| })), | |
| ); | |
| } | |
| const results = await Promise.all(fetchPromises); | |
| // Process results | |
| for (const result of results) { | |
| if (result.type === 'sUSD') { | |
| state.chains[result.chainId].sUSDSupply = result.supply; | |
| } else { | |
| state.chains[result.chainId].adapters[result.token].collateral = | |
| result.balance; | |
| } | |
| } | |
| // Calculate totals for status message | |
| let totalCollateral = 0; | |
| let totalSUSD = 0; | |
| for (const chain of Object.values(state.chains)) { | |
| totalSUSD += chain.sUSDSupply; | |
| for (const adapter of Object.values(chain.adapters)) { | |
| totalCollateral += adapter.collateral; | |
| } | |
| } | |
| const timestamp = new Date().toLocaleTimeString(); | |
| status.innerHTML = `<span style="color: #3fb950;">✓ Live data loaded at ${timestamp}</span> — Total Collateral: ${totalCollateral.toFixed(2)} | Total sUSD: ${totalSUSD.toFixed(2)}`; | |
| addLogEntry( | |
| 'info', | |
| 'Loaded live data from mainnet', | |
| `Arbitrum, Base, Soneium at ${timestamp}`, | |
| ); | |
| render(); | |
| } catch (err) { | |
| console.error('Error loading live data:', err); | |
| status.innerHTML = `<span style="color: #f85149;">✗ Error: ${err.message}</span>`; | |
| addLogEntry('error', 'Failed to load live data', err.message); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = 'Load Live Data'; | |
| } | |
| } | |
| // All possible rebalance routes (same token pairs across chains) | |
| const REBALANCE_ROUTES = [ | |
| { from: 'base', to: 'arbitrum', token: 'USDC' }, | |
| { from: 'base', to: 'arbitrum', token: 'USDT' }, | |
| { from: 'arbitrum', to: 'base', token: 'USDC' }, | |
| { from: 'arbitrum', to: 'base', token: 'USDT' }, | |
| ]; | |
| let state = { | |
| chains: {}, | |
| accumulatedFees: {}, | |
| log: [], | |
| activeFlow: null, // Only set when Transfer/Rebalance is clicked | |
| }; | |
| function initState() { | |
| state = { chains: {}, accumulatedFees: {}, log: [], activeFlow: null }; | |
| for (const [chainId, config] of Object.entries(CHAIN_CONFIG)) { | |
| state.chains[chainId] = { | |
| name: config.name, | |
| domain: config.domain, | |
| color: config.color, | |
| position: config.position, | |
| adapters: {}, | |
| sUSDSupply: 0, | |
| fees: {}, | |
| }; | |
| for (const token of config.tokens) { | |
| state.chains[chainId].adapters[token] = { collateral: 0 }; | |
| state.chains[chainId].fees[token] = 0; | |
| state.accumulatedFees[token] = state.accumulatedFees[token] || 0; | |
| } | |
| } | |
| } | |
| // ============================================ | |
| // PRESETS | |
| // ============================================ | |
| function loadPreset(preset) { | |
| initState(); | |
| switch (preset) { | |
| case 'balanced': | |
| state.chains.base.adapters.USDC.collateral = 1000; | |
| state.chains.base.adapters.USDT.collateral = 800; | |
| state.chains.base.sUSDSupply = 1800; | |
| state.chains.arbitrum.adapters.USDC.collateral = 1000; | |
| state.chains.arbitrum.adapters.USDT.collateral = 800; | |
| state.chains.arbitrum.sUSDSupply = 1800; | |
| state.chains.soneium.adapters.USDSC.collateral = 400; | |
| state.chains.soneium.sUSDSupply = 400; | |
| addLogEntry('info', 'Loaded balanced scenario'); | |
| break; | |
| case 'imbalanced': | |
| state.chains.base.adapters.USDC.collateral = 2000; | |
| state.chains.base.adapters.USDT.collateral = 1500; | |
| state.chains.base.sUSDSupply = 3500; | |
| state.chains.arbitrum.adapters.USDC.collateral = 50; | |
| state.chains.arbitrum.adapters.USDT.collateral = 50; | |
| state.chains.arbitrum.sUSDSupply = 100; | |
| state.chains.soneium.adapters.USDSC.collateral = 400; | |
| state.chains.soneium.sUSDSupply = 400; | |
| addLogEntry( | |
| 'info', | |
| 'Loaded imbalanced scenario', | |
| 'Arbitrum depleted. Transfer will fail, then rebalance.', | |
| ); | |
| break; | |
| case 'appchain': | |
| state.chains.base.adapters.USDC.collateral = 1500; | |
| state.chains.base.adapters.USDT.collateral = 1000; | |
| state.chains.base.sUSDSupply = 2500; | |
| state.chains.arbitrum.adapters.USDC.collateral = 1000; | |
| state.chains.arbitrum.adapters.USDT.collateral = 500; | |
| state.chains.arbitrum.sUSDSupply = 1500; | |
| state.chains.soneium.adapters.USDSC.collateral = 0; | |
| state.chains.soneium.sUSDSupply = 0; | |
| addLogEntry( | |
| 'info', | |
| 'Loaded Soneium bootstrap', | |
| 'Soneium has no collateral yet.', | |
| ); | |
| break; | |
| } | |
| render(); | |
| } | |
| function resetState() { | |
| initState(); | |
| addLogEntry('info', 'State reset'); | |
| render(); | |
| } | |
| // ============================================ | |
| // OPERATIONS | |
| // ============================================ | |
| function executeTransfer() { | |
| const fromChain = document.getElementById('transferFromChain').value; | |
| const toChain = document.getElementById('transferToChain').value; | |
| const inputToken = document.getElementById('transferInputToken').value; | |
| const outputToken = document.getElementById( | |
| 'transferOutputToken', | |
| ).value; | |
| const amount = parseFloat( | |
| document.getElementById('transferAmount').value, | |
| ); | |
| if (!amount || amount <= 0) { | |
| addLogEntry('error', 'Invalid amount'); | |
| return; | |
| } | |
| const fee = (amount * FEE_BPS) / 10000; | |
| const amountAfterFee = amount - fee; | |
| const destAdapter = state.chains[toChain]?.adapters[outputToken]; | |
| if (!destAdapter) { | |
| addLogEntry( | |
| 'error', | |
| `${outputToken} not available on ${state.chains[toChain].name}`, | |
| ); | |
| return; | |
| } | |
| if (destAdapter.collateral < amountAfterFee) { | |
| addLogEntry( | |
| 'error', | |
| `Transfer FAILED: Insufficient ${outputToken} on ${state.chains[toChain].name}`, | |
| `Need ${amountAfterFee.toFixed(2)}, have ${destAdapter.collateral.toFixed(2)}`, | |
| ); | |
| state.activeFlow = { | |
| type: 'transfer', | |
| from: fromChain, | |
| to: toChain, | |
| inputToken, | |
| outputToken, | |
| amount, | |
| amountAfterFee, | |
| fee, | |
| failed: true, | |
| }; | |
| render(); | |
| return; | |
| } | |
| // Execute | |
| state.chains[fromChain].adapters[inputToken].collateral += | |
| amountAfterFee; | |
| state.chains[fromChain].fees[inputToken] += fee; | |
| state.accumulatedFees[inputToken] += fee; | |
| state.chains[toChain].adapters[outputToken].collateral -= | |
| amountAfterFee; | |
| state.activeFlow = { | |
| type: 'transfer', | |
| from: fromChain, | |
| to: toChain, | |
| inputToken, | |
| outputToken, | |
| amount, | |
| amountAfterFee, | |
| fee, | |
| failed: false, | |
| }; | |
| addLogEntry( | |
| 'success', | |
| `Transfer: ${amount} ${inputToken} (${state.chains[fromChain].name}) → ${amountAfterFee.toFixed(2)} ${outputToken} (${state.chains[toChain].name})`, | |
| `Fee: ${fee.toFixed(4)} ${inputToken}`, | |
| ); | |
| render(); | |
| } | |
| function executeRebalance() { | |
| const fromChain = document.getElementById('rebalanceFromChain').value; | |
| const toChain = document.getElementById('rebalanceToChain').value; | |
| const token = document.getElementById('rebalanceToken').value; | |
| const amount = parseFloat( | |
| document.getElementById('rebalanceAmount').value, | |
| ); | |
| if (!amount || amount <= 0) { | |
| addLogEntry('error', 'Invalid amount'); | |
| return; | |
| } | |
| if (!token) { | |
| addLogEntry('error', 'No common token between chains'); | |
| return; | |
| } | |
| const sourceAdapter = state.chains[fromChain]?.adapters[token]; | |
| if (!sourceAdapter || sourceAdapter.collateral < amount) { | |
| addLogEntry( | |
| 'error', | |
| `Rebalance FAILED: Insufficient ${token} on ${state.chains[fromChain].name}`, | |
| `Need ${amount}, have ${(sourceAdapter?.collateral || 0).toFixed(2)}`, | |
| ); | |
| return; | |
| } | |
| // Execute | |
| state.chains[fromChain].adapters[token].collateral -= amount; | |
| state.chains[toChain].adapters[token].collateral += amount; | |
| state.activeFlow = { | |
| type: 'rebalance', | |
| from: fromChain, | |
| to: toChain, | |
| token, | |
| amount, | |
| }; | |
| addLogEntry( | |
| 'rebalance', | |
| `Rebalance: ${amount} ${token} from ${state.chains[fromChain].name} → ${state.chains[toChain].name}`, | |
| `Via underlying ${token} warp route`, | |
| ); | |
| render(); | |
| } | |
| function executeSwap() { | |
| const chain = document.getElementById('swapChain').value; | |
| const fromToken = document.getElementById('swapFromToken').value; | |
| const toToken = document.getElementById('swapToToken').value; | |
| const amount = parseFloat(document.getElementById('swapAmount').value); | |
| if (!amount || amount <= 0) { | |
| addLogEntry('error', 'Invalid amount'); | |
| return; | |
| } | |
| if (fromToken === toToken) { | |
| addLogEntry('error', 'Cannot swap same token'); | |
| return; | |
| } | |
| const fee = (amount * FEE_BPS) / 10000; | |
| const amountAfterFee = amount - fee; | |
| const destAdapter = state.chains[chain]?.adapters[toToken]; | |
| if (!destAdapter) { | |
| addLogEntry( | |
| 'error', | |
| `${toToken} not available on ${state.chains[chain].name}`, | |
| ); | |
| return; | |
| } | |
| if (destAdapter.collateral < amountAfterFee) { | |
| addLogEntry( | |
| 'error', | |
| `Swap FAILED: Insufficient ${toToken} on ${state.chains[chain].name}`, | |
| `Need ${amountAfterFee.toFixed(2)}, have ${destAdapter.collateral.toFixed(2)}`, | |
| ); | |
| state.activeFlow = { | |
| type: 'swap', | |
| chain, | |
| fromToken, | |
| toToken, | |
| amount, | |
| amountAfterFee, | |
| fee, | |
| failed: true, | |
| }; | |
| render(); | |
| return; | |
| } | |
| // Execute: deposit fromToken, redeem toToken (same chain, no sUSD movement) | |
| state.chains[chain].adapters[fromToken].collateral += amountAfterFee; | |
| state.chains[chain].fees[fromToken] += fee; | |
| state.accumulatedFees[fromToken] += fee; | |
| state.chains[chain].adapters[toToken].collateral -= amountAfterFee; | |
| state.activeFlow = { | |
| type: 'swap', | |
| chain, | |
| fromToken, | |
| toToken, | |
| amount, | |
| amountAfterFee, | |
| fee, | |
| failed: false, | |
| }; | |
| addLogEntry( | |
| 'success', | |
| `Swap: ${amount} ${fromToken} → ${amountAfterFee.toFixed(2)} ${toToken} on ${state.chains[chain].name}`, | |
| `Fee: ${fee.toFixed(4)} ${fromToken}. No cross-chain messaging.`, | |
| ); | |
| render(); | |
| } | |
| // ============================================ | |
| // RENDERING | |
| // ============================================ | |
| function render() { | |
| renderDiagram(); | |
| renderConnections(); | |
| renderGlobalStats(); | |
| renderDropdowns(); | |
| updateFlowDescription(); | |
| updateRebalanceDesc(); | |
| updateSwapDesc(); | |
| } | |
| function renderDiagram() { | |
| const container = document.getElementById('triangleContainer'); | |
| const svg = document.getElementById('connectionsSvg'); | |
| // Clear everything except SVG | |
| Array.from(container.children).forEach((child) => { | |
| if (child !== svg) container.removeChild(child); | |
| }); | |
| const flow = state.activeFlow; | |
| for (const chainId of CHAIN_ORDER) { | |
| const chain = state.chains[chainId]; | |
| const col = document.createElement('div'); | |
| col.className = `chain-column ${chain.position}`; | |
| col.id = `chain-col-${chainId}`; | |
| // Determine highlights | |
| const isTransferFrom = | |
| flow?.type === 'transfer' && flow.from === chainId; | |
| const isTransferTo = flow?.type === 'transfer' && flow.to === chainId; | |
| const isRebalanceFrom = | |
| flow?.type === 'rebalance' && flow.from === chainId; | |
| const isRebalanceTo = | |
| flow?.type === 'rebalance' && flow.to === chainId; | |
| col.innerHTML = ` | |
| <div class="chain-header" style="border-color: ${chain.color}"> | |
| <div class="chain-name" style="color: ${chain.color}">${chain.name}</div> | |
| <div class="chain-domain">Domain: ${chain.domain}</div> | |
| </div> | |
| <div class="chain-body"> | |
| <div class="adapters-container"> | |
| <div class="adapters-label">Collateral Adapters</div> | |
| ${Object.entries(chain.adapters) | |
| .map(([token, adapter]) => { | |
| const fee = chain.fees[token] || 0; | |
| const healthClass = getHealthClass( | |
| adapter.collateral, | |
| chain.sUSDSupply, | |
| ); | |
| let highlightClass = ''; | |
| if (flow?.type === 'transfer' && !flow.failed) { | |
| if (flow.from === chainId && flow.inputToken === token) | |
| highlightClass = 'highlight-deposit'; | |
| if (flow.to === chainId && flow.outputToken === token) | |
| highlightClass = 'highlight-redeem'; | |
| } | |
| if (flow?.type === 'rebalance' && flow.token === token) { | |
| if (flow.from === chainId || flow.to === chainId) | |
| highlightClass = 'highlight-rebalance'; | |
| } | |
| if ( | |
| flow?.type === 'swap' && | |
| !flow.failed && | |
| flow.chain === chainId | |
| ) { | |
| if (flow.fromToken === token) | |
| highlightClass = 'highlight-deposit'; | |
| if (flow.toToken === token) | |
| highlightClass = 'highlight-redeem'; | |
| } | |
| return ` | |
| <div class="adapter-box ${highlightClass}" id="adapter-${chainId}-${token}"> | |
| <div class="adapter-header"> | |
| <span class="adapter-token">${token}</span> | |
| <span class="adapter-label">CollateralAdapter</span> | |
| </div> | |
| <div class="adapter-stats"> | |
| <div class="adapter-stat"> | |
| <span class="adapter-stat-label">Collateral:</span> | |
| <span class="adapter-stat-value ${healthClass}">${adapter.collateral.toFixed(2)}</span> | |
| </div> | |
| ${ | |
| fee > 0 | |
| ? ` | |
| <div class="adapter-stat"> | |
| <span class="adapter-stat-label">Fees:</span> | |
| <span class="adapter-stat-value fee">${fee.toFixed(4)}</span> | |
| </div> | |
| ` | |
| : '' | |
| } | |
| </div> | |
| </div> | |
| `; | |
| }) | |
| .join('')} | |
| </div> | |
| <div class="susd-route-container"> | |
| <div class="susd-route-label">sUSD Warp Route</div> | |
| <div class="susd-box ${(flow?.type === 'transfer' && !flow.failed && (isTransferFrom || isTransferTo)) || (flow?.type === 'swap' && !flow.failed && flow.chain === chainId) ? 'highlight' : ''}" | |
| id="susd-${chainId}"> | |
| <div class="susd-header"> | |
| <span class="susd-token">sUSD</span> | |
| <span class="susd-label">HypERC20WithMinters</span> | |
| </div> | |
| <div class="susd-supply">Supply: ${chain.sUSDSupply.toFixed(2)}</div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| container.appendChild(col); | |
| } | |
| } | |
| function renderConnections() { | |
| const svg = document.getElementById('connectionsSvg'); | |
| const container = document.getElementById('triangleContainer'); | |
| requestAnimationFrame(() => { | |
| const containerRect = container.getBoundingClientRect(); | |
| svg.setAttribute('width', containerRect.width); | |
| svg.setAttribute('height', containerRect.height); | |
| svg.setAttribute( | |
| 'viewBox', | |
| `0 0 ${containerRect.width} ${containerRect.height}`, | |
| ); | |
| let paths = ''; | |
| // Arrow markers | |
| paths += ` | |
| <defs> | |
| <marker id="arrowGrey" markerWidth="6" markerHeight="5" refX="5" refY="2.5" orient="auto"> | |
| <polygon points="0 0, 6 2.5, 0 5" fill="#484f58"/> | |
| </marker> | |
| <marker id="arrowBlue" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"> | |
| <polygon points="0 0, 8 3, 0 6" fill="#58a6ff"/> | |
| </marker> | |
| <marker id="arrowPurple" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"> | |
| <polygon points="0 0, 8 3, 0 6" fill="#a371f7"/> | |
| </marker> | |
| <marker id="arrowGreen" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"> | |
| <polygon points="0 0, 8 3, 0 6" fill="#3fb950"/> | |
| </marker> | |
| <marker id="arrowOrange" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"> | |
| <polygon points="0 0, 8 3, 0 6" fill="#f97316"/> | |
| </marker> | |
| <marker id="arrowPink" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"> | |
| <polygon points="0 0, 8 3, 0 6" fill="#ec4899"/> | |
| </marker> | |
| <marker id="arrowTeal" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"> | |
| <polygon points="0 0, 8 3, 0 6" fill="#10b981"/> | |
| </marker> | |
| </defs> | |
| `; | |
| // Helper functions | |
| const getElementRect = (id) => { | |
| const el = document.getElementById(id); | |
| if (!el) return null; | |
| const rect = el.getBoundingClientRect(); | |
| return { | |
| x: rect.left - containerRect.left + rect.width / 2, | |
| y: rect.top - containerRect.top + rect.height / 2, | |
| top: rect.top - containerRect.top, | |
| bottom: rect.bottom - containerRect.top, | |
| left: rect.left - containerRect.left, | |
| right: rect.right - containerRect.left, | |
| width: rect.width, | |
| height: rect.height, | |
| }; | |
| }; | |
| const flow = state.activeFlow; | |
| // ======================================== | |
| // 1. Draw deposit/redeem edges: adapter ↔ sUSD (within each chain) | |
| // ======================================== | |
| for (const chainId of CHAIN_ORDER) { | |
| const chain = state.chains[chainId]; | |
| const susd = getElementRect(`susd-${chainId}`); | |
| for (const token of Object.keys(chain.adapters)) { | |
| const adapter = getElementRect(`adapter-${chainId}-${token}`); | |
| if (!adapter || !susd) continue; | |
| // Is this edge part of active flow? | |
| const isActiveDeposit = | |
| (flow?.type === 'transfer' && | |
| !flow.failed && | |
| flow.from === chainId && | |
| flow.inputToken === token) || | |
| (flow?.type === 'swap' && | |
| !flow.failed && | |
| flow.chain === chainId && | |
| flow.fromToken === token); | |
| const isActiveRedeem = | |
| (flow?.type === 'transfer' && | |
| !flow.failed && | |
| flow.to === chainId && | |
| flow.outputToken === token) || | |
| (flow?.type === 'swap' && | |
| !flow.failed && | |
| flow.chain === chainId && | |
| flow.toToken === token); | |
| // Deposit edge: adapter → sUSD (on the left side) | |
| const depStart = { x: adapter.left + 15, y: adapter.bottom }; | |
| const depEnd = { x: susd.left + 15, y: susd.top }; | |
| const depCtrlX = depStart.x - 25; | |
| const depCtrlY = (depStart.y + depEnd.y) / 2; | |
| if (isActiveDeposit) { | |
| paths += `<path d="M ${depStart.x} ${depStart.y} Q ${depCtrlX} ${depCtrlY} ${depEnd.x} ${depEnd.y}" | |
| fill="none" stroke="#58a6ff" stroke-width="2.5" class="animated-path" | |
| marker-end="url(#arrowBlue)"/>`; | |
| paths += createEdgeLabel( | |
| depCtrlX - 25, | |
| depCtrlY, | |
| 'deposit', | |
| `+${flow.amountAfterFee.toFixed(1)}`, | |
| '#58a6ff', | |
| false, | |
| ); | |
| } else { | |
| paths += `<path d="M ${depStart.x} ${depStart.y} Q ${depCtrlX} ${depCtrlY} ${depEnd.x} ${depEnd.y}" | |
| fill="none" stroke="#484f58" stroke-width="1.5" opacity="0.4" | |
| marker-end="url(#arrowGrey)"/>`; | |
| paths += createEdgeLabel( | |
| depCtrlX - 20, | |
| depCtrlY, | |
| 'deposit', | |
| token, | |
| '#484f58', | |
| true, | |
| ); | |
| } | |
| // Redeem edge: sUSD → adapter (on the right side) | |
| const redStart = { x: susd.right - 15, y: susd.top }; | |
| const redEnd = { x: adapter.right - 15, y: adapter.bottom }; | |
| const redCtrlX = redEnd.x + 25; | |
| const redCtrlY = (redStart.y + redEnd.y) / 2; | |
| if (isActiveRedeem) { | |
| paths += `<path d="M ${redStart.x} ${redStart.y} Q ${redCtrlX} ${redCtrlY} ${redEnd.x} ${redEnd.y}" | |
| fill="none" stroke="#3fb950" stroke-width="2.5" class="animated-path" | |
| marker-end="url(#arrowGreen)"/>`; | |
| paths += createEdgeLabel( | |
| redCtrlX + 25, | |
| redCtrlY, | |
| 'redeem', | |
| `-${flow.amountAfterFee.toFixed(1)}`, | |
| '#3fb950', | |
| false, | |
| ); | |
| } else { | |
| paths += `<path d="M ${redStart.x} ${redStart.y} Q ${redCtrlX} ${redCtrlY} ${redEnd.x} ${redEnd.y}" | |
| fill="none" stroke="#484f58" stroke-width="1.5" opacity="0.4" | |
| marker-end="url(#arrowGrey)"/>`; | |
| paths += createEdgeLabel( | |
| redCtrlX + 20, | |
| redCtrlY, | |
| 'redeem', | |
| token, | |
| '#484f58', | |
| true, | |
| ); | |
| } | |
| } | |
| } | |
| // ======================================== | |
| // 2. Draw sUSD transferRemote edges (between sUSD boxes) | |
| // ======================================== | |
| const chainPairs = [ | |
| ['base', 'arbitrum'], | |
| ['base', 'soneium'], | |
| ['arbitrum', 'soneium'], | |
| ]; | |
| for (const [c1, c2] of chainPairs) { | |
| const susd1 = getElementRect(`susd-${c1}`); | |
| const susd2 = getElementRect(`susd-${c2}`); | |
| if (!susd1 || !susd2) continue; | |
| const midX = (susd1.x + susd2.x) / 2; | |
| const midY = (susd1.y + susd2.y) / 2; | |
| const dx = susd2.x - susd1.x; | |
| const dy = susd2.y - susd1.y; | |
| const len = Math.sqrt(dx * dx + dy * dy); | |
| const offset = 50; | |
| const perpX = (-dy / len) * offset; | |
| const perpY = (dx / len) * offset; | |
| // Direction 1: c1 → c2 | |
| const isActive1 = | |
| flow?.type === 'transfer' && | |
| !flow.failed && | |
| flow.from === c1 && | |
| flow.to === c2; | |
| const ctrlX1 = midX + perpX; | |
| const ctrlY1 = midY + perpY; | |
| if (isActive1) { | |
| paths += `<path d="M ${susd1.x} ${susd1.y} Q ${ctrlX1} ${ctrlY1} ${susd2.x} ${susd2.y}" | |
| fill="none" stroke="#a371f7" stroke-width="2.5" stroke-dasharray="6,3" class="animated-path" | |
| marker-end="url(#arrowPurple)"/>`; | |
| paths += createEdgeLabel( | |
| ctrlX1, | |
| ctrlY1 - 12, | |
| 'transferRemote', | |
| `${flow.amountAfterFee.toFixed(1)} sUSD`, | |
| '#a371f7', | |
| false, | |
| ); | |
| } else { | |
| paths += `<path d="M ${susd1.x} ${susd1.y} Q ${ctrlX1} ${ctrlY1} ${susd2.x} ${susd2.y}" | |
| fill="none" stroke="#484f58" stroke-width="1.5" opacity="0.35" | |
| marker-end="url(#arrowGrey)"/>`; | |
| paths += createEdgeLabel( | |
| ctrlX1, | |
| ctrlY1 - 8, | |
| 'transferRemote', | |
| `${state.chains[c1].name}→${state.chains[c2].name}`, | |
| '#484f58', | |
| true, | |
| ); | |
| } | |
| // Direction 2: c2 → c1 | |
| const isActive2 = | |
| flow?.type === 'transfer' && | |
| !flow.failed && | |
| flow.from === c2 && | |
| flow.to === c1; | |
| const ctrlX2 = midX - perpX; | |
| const ctrlY2 = midY - perpY; | |
| if (isActive2) { | |
| paths += `<path d="M ${susd2.x} ${susd2.y} Q ${ctrlX2} ${ctrlY2} ${susd1.x} ${susd1.y}" | |
| fill="none" stroke="#a371f7" stroke-width="2.5" stroke-dasharray="6,3" class="animated-path" | |
| marker-end="url(#arrowPurple)"/>`; | |
| paths += createEdgeLabel( | |
| ctrlX2, | |
| ctrlY2 + 12, | |
| 'transferRemote', | |
| `${flow.amountAfterFee.toFixed(1)} sUSD`, | |
| '#a371f7', | |
| false, | |
| ); | |
| } else { | |
| paths += `<path d="M ${susd2.x} ${susd2.y} Q ${ctrlX2} ${ctrlY2} ${susd1.x} ${susd1.y}" | |
| fill="none" stroke="#484f58" stroke-width="1.5" opacity="0.35" | |
| marker-end="url(#arrowGrey)"/>`; | |
| paths += createEdgeLabel( | |
| ctrlX2, | |
| ctrlY2 + 8, | |
| 'transferRemote', | |
| `${state.chains[c2].name}→${state.chains[c1].name}`, | |
| '#484f58', | |
| true, | |
| ); | |
| } | |
| } | |
| // ======================================== | |
| // 3. Draw rebalance edges (adapter → adapter, same token) | |
| // ======================================== | |
| for (const route of REBALANCE_ROUTES) { | |
| const from = getElementRect(`adapter-${route.from}-${route.token}`); | |
| const to = getElementRect(`adapter-${route.to}-${route.token}`); | |
| if (!from || !to) continue; | |
| const isActive = | |
| flow?.type === 'rebalance' && | |
| flow.from === route.from && | |
| flow.to === route.to && | |
| flow.token === route.token; | |
| const midX = (from.x + to.x) / 2; | |
| const midY = (from.y + to.y) / 2 - 20; | |
| if (isActive) { | |
| paths += `<path d="M ${from.right} ${from.y} Q ${midX} ${midY} ${to.left} ${to.y}" | |
| fill="none" stroke="#f97316" stroke-width="2.5" stroke-dasharray="6,3" class="animated-path" | |
| marker-end="url(#arrowOrange)"/>`; | |
| paths += createEdgeLabel( | |
| midX, | |
| midY - 10, | |
| 'rebalance', | |
| `${flow.amount} ${route.token}`, | |
| '#f97316', | |
| false, | |
| ); | |
| } else { | |
| paths += `<path d="M ${from.right} ${from.y} Q ${midX} ${midY} ${to.left} ${to.y}" | |
| fill="none" stroke="#484f58" stroke-width="1.5" stroke-dasharray="4,3" opacity="0.3" | |
| marker-end="url(#arrowGrey)"/>`; | |
| paths += createEdgeLabel( | |
| midX, | |
| midY - 6, | |
| 'rebalance', | |
| route.token, | |
| '#484f58', | |
| true, | |
| ); | |
| } | |
| } | |
| // ======================================== | |
| // 4. Draw user boxes and edges for transfers/swaps | |
| // ======================================== | |
| if (flow?.type === 'transfer' && !flow.failed) { | |
| const fromAdapter = getElementRect( | |
| `adapter-${flow.from}-${flow.inputToken}`, | |
| ); | |
| const toAdapter = getElementRect( | |
| `adapter-${flow.to}-${flow.outputToken}`, | |
| ); | |
| if (fromAdapter && toAdapter) { | |
| // Sender user box (positioned to the left of origin adapter) | |
| const senderX = fromAdapter.left - 100; | |
| const senderY = fromAdapter.y - 30; | |
| const senderBoxWidth = 85; | |
| const senderBoxHeight = 55; | |
| paths += ` | |
| <rect x="${senderX}" y="${senderY}" width="${senderBoxWidth}" height="${senderBoxHeight}" | |
| rx="6" fill="rgba(236, 72, 153, 0.15)" stroke="#ec4899" stroke-width="2"/> | |
| <text x="${senderX + senderBoxWidth / 2}" y="${senderY + 18}" fill="#ec4899" font-size="14" text-anchor="middle">👤</text> | |
| <text x="${senderX + senderBoxWidth / 2}" y="${senderY + 32}" fill="#ec4899" font-size="8" text-anchor="middle" font-weight="bold">SENDER</text> | |
| <text x="${senderX + senderBoxWidth / 2}" y="${senderY + 45}" fill="#f0f6fc" font-size="9" text-anchor="middle" font-family="monospace">-${flow.amount} ${flow.inputToken}</text> | |
| `; | |
| // Edge from sender to adapter | |
| const senderEdgeStartX = senderX + senderBoxWidth; | |
| const senderEdgeStartY = senderY + senderBoxHeight / 2; | |
| const senderEdgeEndX = fromAdapter.left; | |
| const senderEdgeEndY = fromAdapter.y; | |
| paths += `<path d="M ${senderEdgeStartX} ${senderEdgeStartY} L ${senderEdgeEndX} ${senderEdgeEndY}" | |
| fill="none" stroke="#ec4899" stroke-width="2.5" class="animated-path" | |
| marker-end="url(#arrowPink)"/>`; | |
| // Receiver user box (positioned to the right of destination adapter) | |
| const receiverX = toAdapter.right + 15; | |
| const receiverY = toAdapter.y - 30; | |
| const receiverBoxWidth = 85; | |
| const receiverBoxHeight = 55; | |
| paths += ` | |
| <rect x="${receiverX}" y="${receiverY}" width="${receiverBoxWidth}" height="${receiverBoxHeight}" | |
| rx="6" fill="rgba(16, 185, 129, 0.15)" stroke="#10b981" stroke-width="2"/> | |
| <text x="${receiverX + receiverBoxWidth / 2}" y="${receiverY + 18}" fill="#10b981" font-size="14" text-anchor="middle">👤</text> | |
| <text x="${receiverX + receiverBoxWidth / 2}" y="${receiverY + 32}" fill="#10b981" font-size="8" text-anchor="middle" font-weight="bold">RECEIVER</text> | |
| <text x="${receiverX + receiverBoxWidth / 2}" y="${receiverY + 45}" fill="#f0f6fc" font-size="9" text-anchor="middle" font-family="monospace">+${flow.amountAfterFee.toFixed(2)} ${flow.outputToken}</text> | |
| `; | |
| // Edge from adapter to receiver | |
| const receiverEdgeStartX = toAdapter.right; | |
| const receiverEdgeStartY = toAdapter.y; | |
| const receiverEdgeEndX = receiverX; | |
| const receiverEdgeEndY = receiverY + receiverBoxHeight / 2; | |
| paths += `<path d="M ${receiverEdgeStartX} ${receiverEdgeStartY} L ${receiverEdgeEndX} ${receiverEdgeEndY}" | |
| fill="none" stroke="#10b981" stroke-width="2.5" class="animated-path" | |
| marker-end="url(#arrowTeal)"/>`; | |
| } | |
| } | |
| // User boxes for same-chain swap | |
| if (flow?.type === 'swap' && !flow.failed) { | |
| const fromAdapter = getElementRect( | |
| `adapter-${flow.chain}-${flow.fromToken}`, | |
| ); | |
| const toAdapter = getElementRect( | |
| `adapter-${flow.chain}-${flow.toToken}`, | |
| ); | |
| if (fromAdapter && toAdapter) { | |
| // Sender user box (positioned to the left of input adapter) | |
| const senderX = fromAdapter.left - 100; | |
| const senderY = fromAdapter.y - 30; | |
| const senderBoxWidth = 85; | |
| const senderBoxHeight = 55; | |
| paths += ` | |
| <rect x="${senderX}" y="${senderY}" width="${senderBoxWidth}" height="${senderBoxHeight}" | |
| rx="6" fill="rgba(236, 72, 153, 0.15)" stroke="#ec4899" stroke-width="2"/> | |
| <text x="${senderX + senderBoxWidth / 2}" y="${senderY + 18}" fill="#ec4899" font-size="14" text-anchor="middle">👤</text> | |
| <text x="${senderX + senderBoxWidth / 2}" y="${senderY + 32}" fill="#ec4899" font-size="8" text-anchor="middle" font-weight="bold">USER IN</text> | |
| <text x="${senderX + senderBoxWidth / 2}" y="${senderY + 45}" fill="#f0f6fc" font-size="9" text-anchor="middle" font-family="monospace">-${flow.amount} ${flow.fromToken}</text> | |
| `; | |
| // Edge from sender to adapter | |
| const senderEdgeStartX = senderX + senderBoxWidth; | |
| const senderEdgeStartY = senderY + senderBoxHeight / 2; | |
| const senderEdgeEndX = fromAdapter.left; | |
| const senderEdgeEndY = fromAdapter.y; | |
| paths += `<path d="M ${senderEdgeStartX} ${senderEdgeStartY} L ${senderEdgeEndX} ${senderEdgeEndY}" | |
| fill="none" stroke="#ec4899" stroke-width="2.5" class="animated-path" | |
| marker-end="url(#arrowPink)"/>`; | |
| // Receiver user box (positioned to the right of output adapter) | |
| const receiverX = toAdapter.right + 15; | |
| const receiverY = toAdapter.y - 30; | |
| const receiverBoxWidth = 85; | |
| const receiverBoxHeight = 55; | |
| paths += ` | |
| <rect x="${receiverX}" y="${receiverY}" width="${receiverBoxWidth}" height="${receiverBoxHeight}" | |
| rx="6" fill="rgba(16, 185, 129, 0.15)" stroke="#10b981" stroke-width="2"/> | |
| <text x="${receiverX + receiverBoxWidth / 2}" y="${receiverY + 18}" fill="#10b981" font-size="14" text-anchor="middle">👤</text> | |
| <text x="${receiverX + receiverBoxWidth / 2}" y="${receiverY + 32}" fill="#10b981" font-size="8" text-anchor="middle" font-weight="bold">USER OUT</text> | |
| <text x="${receiverX + receiverBoxWidth / 2}" y="${receiverY + 45}" fill="#f0f6fc" font-size="9" text-anchor="middle" font-family="monospace">+${flow.amountAfterFee.toFixed(2)} ${flow.toToken}</text> | |
| `; | |
| // Edge from adapter to receiver | |
| const receiverEdgeStartX = toAdapter.right; | |
| const receiverEdgeStartY = toAdapter.y; | |
| const receiverEdgeEndX = receiverX; | |
| const receiverEdgeEndY = receiverY + receiverBoxHeight / 2; | |
| paths += `<path d="M ${receiverEdgeStartX} ${receiverEdgeStartY} L ${receiverEdgeEndX} ${receiverEdgeEndY}" | |
| fill="none" stroke="#10b981" stroke-width="2.5" class="animated-path" | |
| marker-end="url(#arrowTeal)"/>`; | |
| } | |
| } | |
| // ======================================== | |
| // 5. Show failure indicator | |
| // ======================================== | |
| if (flow?.type === 'transfer' && flow.failed) { | |
| const toAdapter = getElementRect( | |
| `adapter-${flow.to}-${flow.outputToken}`, | |
| ); | |
| if (toAdapter) { | |
| paths += `<rect x="${toAdapter.x - 70}" y="${toAdapter.top - 30}" width="140" height="22" rx="4" fill="#f85149" opacity="0.9"/>`; | |
| paths += `<text x="${toAdapter.x}" y="${toAdapter.top - 15}" fill="#fff" font-size="10" text-anchor="middle" font-weight="bold">✗ Insufficient collateral</text>`; | |
| } | |
| } | |
| if (flow?.type === 'swap' && flow.failed) { | |
| const toAdapter = getElementRect( | |
| `adapter-${flow.chain}-${flow.toToken}`, | |
| ); | |
| if (toAdapter) { | |
| paths += `<rect x="${toAdapter.x - 70}" y="${toAdapter.top - 30}" width="140" height="22" rx="4" fill="#f85149" opacity="0.9"/>`; | |
| paths += `<text x="${toAdapter.x}" y="${toAdapter.top - 15}" fill="#fff" font-size="10" text-anchor="middle" font-weight="bold">✗ Insufficient collateral</text>`; | |
| } | |
| } | |
| svg.innerHTML = paths; | |
| }); | |
| } | |
| function createEdgeLabel(x, y, action, amount, color, small) { | |
| const fontSize = small ? 6 : 7; | |
| const amountSize = small ? 7 : 8; | |
| const padding = small ? 2 : 3; | |
| const actionWidth = action.length * (small ? 4 : 5); | |
| const amountWidth = amount.length * (small ? 4.5 : 5.5); | |
| const textWidth = Math.max(actionWidth, amountWidth) + padding * 2; | |
| const textHeight = small ? 18 : 22; | |
| const opacity = small ? 0.7 : 0.95; | |
| return ` | |
| <rect x="${x - textWidth / 2}" y="${y - textHeight / 2}" width="${textWidth}" height="${textHeight}" | |
| rx="3" fill="#0d1117" stroke="${color}" stroke-width="1" opacity="${opacity}"/> | |
| <text x="${x}" y="${y - 2}" fill="${color}" font-size="${fontSize}" text-anchor="middle" font-weight="bold" font-family="monospace">${action}</text> | |
| <text x="${x}" y="${y + (small ? 6 : 7)}" fill="${color}" font-size="${amountSize}" text-anchor="middle" font-family="monospace">${amount}</text> | |
| `; | |
| } | |
| function getHealthClass(collateral, sUSDSupply) { | |
| if (sUSDSupply === 0) return collateral > 0 ? 'healthy' : ''; | |
| const ratio = collateral / sUSDSupply; | |
| if (ratio < 0.1) return 'depleted'; | |
| if (ratio < 0.3) return 'low'; | |
| return 'healthy'; | |
| } | |
| function renderGlobalStats() { | |
| let totalSUSD = 0, | |
| totalCollateral = 0; | |
| for (const chain of Object.values(state.chains)) { | |
| totalSUSD += chain.sUSDSupply; | |
| for (const adapter of Object.values(chain.adapters)) { | |
| totalCollateral += adapter.collateral; | |
| } | |
| } | |
| const totalFees = Object.values(state.accumulatedFees).reduce( | |
| (a, b) => a + b, | |
| 0, | |
| ); | |
| document.getElementById('totalSUSD').textContent = totalSUSD.toFixed(2); | |
| document.getElementById('totalCollateral').textContent = | |
| totalCollateral.toFixed(2); | |
| document.getElementById('totalFees').textContent = totalFees.toFixed(4); | |
| const invariantEl = document.getElementById('invariantCheck'); | |
| const warningEl = document.getElementById('invariantWarning'); | |
| const diff = Math.abs(totalSUSD - totalCollateral); | |
| if (diff < 0.01) { | |
| invariantEl.textContent = '✓ Valid'; | |
| invariantEl.className = 'stat-value success'; | |
| warningEl.classList.remove('show'); | |
| } else { | |
| invariantEl.textContent = '✗ VIOLATED'; | |
| invariantEl.className = 'stat-value error'; | |
| warningEl.classList.add('show'); | |
| } | |
| } | |
| function renderDropdowns() { | |
| const chains = Object.keys(state.chains); | |
| [ | |
| 'transferFromChain', | |
| 'transferToChain', | |
| 'rebalanceFromChain', | |
| 'rebalanceToChain', | |
| 'swapChain', | |
| ].forEach((id, i) => { | |
| const select = document.getElementById(id); | |
| const current = select.value; | |
| select.innerHTML = chains | |
| .map((c) => `<option value="${c}">${state.chains[c].name}</option>`) | |
| .join(''); | |
| select.value = current || chains[i % 2]; | |
| }); | |
| if ( | |
| !document.getElementById('transferToChain').value || | |
| document.getElementById('transferToChain').value === | |
| document.getElementById('transferFromChain').value | |
| ) { | |
| document.getElementById('transferToChain').value = chains[1]; | |
| } | |
| if ( | |
| !document.getElementById('rebalanceToChain').value || | |
| document.getElementById('rebalanceToChain').value === | |
| document.getElementById('rebalanceFromChain').value | |
| ) { | |
| document.getElementById('rebalanceToChain').value = chains[1]; | |
| } | |
| updateTransferTokens(); | |
| updateTransferOutputTokens(); | |
| updateRebalanceTokens(); | |
| updateSwapTokens(); | |
| } | |
| function updateTransferTokens() { | |
| const chain = document.getElementById('transferFromChain').value; | |
| const tokens = Object.keys(state.chains[chain].adapters); | |
| document.getElementById('transferInputToken').innerHTML = tokens | |
| .map((t) => `<option value="${t}">${t}</option>`) | |
| .join(''); | |
| } | |
| function updateTransferOutputTokens() { | |
| const chain = document.getElementById('transferToChain').value; | |
| const tokens = Object.keys(state.chains[chain].adapters); | |
| document.getElementById('transferOutputToken').innerHTML = tokens | |
| .map((t) => `<option value="${t}">${t}</option>`) | |
| .join(''); | |
| } | |
| function updateRebalanceTokens() { | |
| const from = document.getElementById('rebalanceFromChain').value; | |
| const to = document.getElementById('rebalanceToChain').value; | |
| const fromTokens = new Set(Object.keys(state.chains[from].adapters)); | |
| const toTokens = new Set(Object.keys(state.chains[to].adapters)); | |
| const common = [...fromTokens].filter((t) => toTokens.has(t)); | |
| const select = document.getElementById('rebalanceToken'); | |
| select.innerHTML = | |
| common.length > 0 | |
| ? common.map((t) => `<option value="${t}">${t}</option>`).join('') | |
| : '<option value="">No common tokens</option>'; | |
| } | |
| function updateFlowDescription() { | |
| const from = document.getElementById('transferFromChain').value; | |
| const to = document.getElementById('transferToChain').value; | |
| const input = document.getElementById('transferInputToken').value; | |
| const output = document.getElementById('transferOutputToken').value; | |
| document.getElementById('transferFlowDesc').innerHTML = | |
| `<span style="color:#58a6ff">① deposit</span> ${input} → | |
| <span style="color:#a371f7">② transferRemote</span> sUSD → | |
| <span style="color:#3fb950">③ redeem</span> ${output}`; | |
| } | |
| function updateRebalanceDesc() { | |
| const from = document.getElementById('rebalanceFromChain').value; | |
| const to = document.getElementById('rebalanceToChain').value; | |
| const token = document.getElementById('rebalanceToken').value; | |
| if (!token) { | |
| document.getElementById('rebalanceFlowDesc').innerHTML = | |
| '<em>No common token to rebalance</em>'; | |
| return; | |
| } | |
| document.getElementById('rebalanceFlowDesc').innerHTML = | |
| `${token} Adapter (${state.chains[from]?.name}) → <span style="color:#f97316">underlying ${token} warp</span> → ${token} Adapter (${state.chains[to]?.name})`; | |
| } | |
| function updateSwapTokens() { | |
| const chain = document.getElementById('swapChain').value; | |
| const tokens = Object.keys(state.chains[chain]?.adapters || {}); | |
| const fromSelect = document.getElementById('swapFromToken'); | |
| const toSelect = document.getElementById('swapToToken'); | |
| const currentFrom = fromSelect.value; | |
| const currentTo = toSelect.value; | |
| fromSelect.innerHTML = tokens | |
| .map((t) => `<option value="${t}">${t}</option>`) | |
| .join(''); | |
| toSelect.innerHTML = tokens | |
| .map((t) => `<option value="${t}">${t}</option>`) | |
| .join(''); | |
| // Try to keep current selections or pick different defaults | |
| if (tokens.includes(currentFrom)) { | |
| fromSelect.value = currentFrom; | |
| } | |
| if (tokens.includes(currentTo) && currentTo !== fromSelect.value) { | |
| toSelect.value = currentTo; | |
| } else if (tokens.length > 1) { | |
| toSelect.value = | |
| tokens.find((t) => t !== fromSelect.value) || tokens[1]; | |
| } | |
| } | |
| function updateSwapDesc() { | |
| const chain = document.getElementById('swapChain').value; | |
| const fromToken = document.getElementById('swapFromToken').value; | |
| const toToken = document.getElementById('swapToToken').value; | |
| const chainName = state.chains[chain]?.name || chain; | |
| if (!fromToken || !toToken) { | |
| document.getElementById('swapFlowDesc').innerHTML = | |
| '<em>Select tokens to swap</em>'; | |
| return; | |
| } | |
| if (fromToken === toToken) { | |
| document.getElementById('swapFlowDesc').innerHTML = | |
| '<em style="color:#f85149">Cannot swap same token</em>'; | |
| return; | |
| } | |
| document.getElementById('swapFlowDesc').innerHTML = | |
| `<span style="color:#58a6ff">① deposit</span> ${fromToken} → mint sUSD → <span style="color:#3fb950">② redeem</span> ${toToken} (on ${chainName})`; | |
| } | |
| // ============================================ | |
| // LOGGING | |
| // ============================================ | |
| function addLogEntry(type, message, details) { | |
| state.log.unshift({ | |
| type, | |
| message, | |
| details, | |
| timestamp: new Date().toLocaleTimeString(), | |
| }); | |
| renderLog(); | |
| } | |
| function clearLog() { | |
| state.log = []; | |
| renderLog(); | |
| } | |
| function renderLog() { | |
| document.getElementById('logEntries').innerHTML = state.log | |
| .map( | |
| (e) => ` | |
| <div class="log-entry ${e.type}"> | |
| <span class="log-timestamp">${e.timestamp}</span> | |
| <span class="log-message">${e.message}</span> | |
| ${e.details ? `<div class="log-details">${e.details}</div>` : ''} | |
| </div> | |
| `, | |
| ) | |
| .join(''); | |
| } | |
| // ============================================ | |
| // INIT | |
| // ============================================ | |
| initState(); | |
| loadPreset('balanced'); | |
| window.addEventListener('resize', renderConnections); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment