|
| 1 | +import { get, writable } from 'svelte/store' |
| 2 | +import { loadSession } from './gs-crypto' |
| 3 | +import { wsPort } from './websocket' |
| 4 | + |
| 5 | +const PENDING_KEY = 'groundseg:keys:pending' |
| 6 | +const MIN_POLL_INTERVAL = 60 |
| 7 | +const MAX_POLL_INTERVAL = 300 |
| 8 | +const TERMINAL_STATUSES = new Set(['complete', 'confirmed', 'failed']) |
| 9 | + |
| 10 | +export const keyPending = writable([]) |
| 11 | + |
| 12 | +const apiBase = () => { |
| 13 | + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:' |
| 14 | + return `${protocol}//${window.location.hostname}:${get(wsPort)}` |
| 15 | +} |
| 16 | + |
| 17 | +const withToken = async payload => { |
| 18 | + const token = await loadSession() |
| 19 | + if (!token?.id || !token?.token) { |
| 20 | + throw new Error('GroundSeg login required') |
| 21 | + } |
| 22 | + return { ...payload, token } |
| 23 | +} |
| 24 | + |
| 25 | +export const keysRequest = async (path, payload = {}) => { |
| 26 | + const response = await fetch(`${apiBase()}${path}`, { |
| 27 | + method: 'POST', |
| 28 | + headers: { 'Content-Type': 'application/json' }, |
| 29 | + body: JSON.stringify(await withToken(payload)) |
| 30 | + }) |
| 31 | + let body = {} |
| 32 | + try { |
| 33 | + body = await response.json() |
| 34 | + } catch (error) { |
| 35 | + throw new Error(`Keys request failed: ${response.status}`) |
| 36 | + } |
| 37 | + if (!response.ok || body.ok === false) { |
| 38 | + throw new Error(body.error || `Keys request failed: ${response.status}`) |
| 39 | + } |
| 40 | + return body |
| 41 | +} |
| 42 | + |
| 43 | +const readPending = () => { |
| 44 | + if (typeof localStorage === 'undefined') return [] |
| 45 | + try { |
| 46 | + const parsed = JSON.parse(localStorage.getItem(PENDING_KEY) || '[]') |
| 47 | + return Array.isArray(parsed) ? parsed : [] |
| 48 | + } catch (error) { |
| 49 | + return [] |
| 50 | + } |
| 51 | +} |
| 52 | + |
| 53 | +const writePending = items => { |
| 54 | + keyPending.set(items) |
| 55 | + if (typeof localStorage !== 'undefined') { |
| 56 | + localStorage.setItem(PENDING_KEY, JSON.stringify(items)) |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +export const loadKeyPending = () => { |
| 61 | + writePending(readPending()) |
| 62 | +} |
| 63 | + |
| 64 | +export const addKeyPending = tx => { |
| 65 | + if (!tx) return |
| 66 | + const id = tx.hash || tx.signature || `${tx.ship}-${tx.operation}-${tx.submittedAt}` |
| 67 | + const next = [ |
| 68 | + tx, |
| 69 | + ...get(keyPending).filter(item => (item.hash || item.signature || `${item.ship}-${item.operation}-${item.submittedAt}`) !== id) |
| 70 | + ] |
| 71 | + writePending(next.slice(0, 12)) |
| 72 | +} |
| 73 | + |
| 74 | +export const removeKeyPending = tx => { |
| 75 | + const id = tx.hash || tx.signature || `${tx.ship}-${tx.operation}-${tx.submittedAt}` |
| 76 | + writePending(get(keyPending).filter(item => (item.hash || item.signature || `${item.ship}-${item.operation}-${item.submittedAt}`) !== id)) |
| 77 | +} |
| 78 | + |
| 79 | +export const updateKeyPending = updated => { |
| 80 | + const id = updated.hash || updated.signature || `${updated.ship}-${updated.operation}-${updated.submittedAt}` |
| 81 | + writePending(get(keyPending).map(item => { |
| 82 | + const itemId = item.hash || item.signature || `${item.ship}-${item.operation}-${item.submittedAt}` |
| 83 | + return itemId === id ? updated : item |
| 84 | + })) |
| 85 | +} |
| 86 | + |
| 87 | +export const getPoint = (ship, roller = '') => keysRequest('/keys/point', { ship, roller }) |
| 88 | + |
| 89 | +export const checkPending = tx => keysRequest('/keys/point', { |
| 90 | + ship: tx.ship, |
| 91 | + hash: tx.hash, |
| 92 | + roller: tx.roller |
| 93 | +}) |
| 94 | + |
| 95 | +export const generateKeyfile = payload => keysRequest('/keys/keyfile', payload) |
| 96 | + |
| 97 | +export const generateCode = payload => keysRequest('/keys/code', payload) |
| 98 | + |
| 99 | +export const submitKeyOperation = payload => keysRequest('/keys/operation', payload) |
| 100 | + |
| 101 | +export const prepareWalletOperation = payload => keysRequest('/keys/wallet/prepare', payload) |
| 102 | + |
| 103 | +export const submitWalletOperation = payload => keysRequest('/keys/wallet/submit', payload) |
| 104 | + |
| 105 | +const containsPendingTx = (pending, tx) => { |
| 106 | + if (!Array.isArray(pending)) return false |
| 107 | + return pending.some(item => { |
| 108 | + const sig = item?.rawTx?.sig || item?.rawTx?.Sig |
| 109 | + const hash = item?.hash || item?.Hash |
| 110 | + return (tx.signature && sig === tx.signature) || (tx.hash && hash === tx.hash) |
| 111 | + }) |
| 112 | +} |
| 113 | + |
| 114 | +const nextPollFromBatch = (batch, currentInterval = MIN_POLL_INTERVAL) => { |
| 115 | + const batchWait = Number(batch?.timeUntilNext) |
| 116 | + if (Number.isFinite(batchWait) && batchWait > 0) { |
| 117 | + return Math.max(MIN_POLL_INTERVAL, Math.min(MAX_POLL_INTERVAL, batchWait + 15)) |
| 118 | + } |
| 119 | + return Math.max(MIN_POLL_INTERVAL, Math.min(MAX_POLL_INTERVAL, Math.ceil(currentInterval * 1.5))) |
| 120 | +} |
| 121 | + |
| 122 | +export const pollDueKeyPending = async (force = false) => { |
| 123 | + const now = Date.now() |
| 124 | + const items = get(keyPending) |
| 125 | + for (const tx of items) { |
| 126 | + if (!force && TERMINAL_STATUSES.has(tx.status)) continue |
| 127 | + if (!force && tx.nextPollAt && tx.nextPollAt > now) continue |
| 128 | + try { |
| 129 | + const result = await checkPending(tx) |
| 130 | + const status = result.status || '' |
| 131 | + const stillPending = containsPendingTx(result.pending, tx) || Boolean(result.pendingTx) |
| 132 | + if (status === 'confirmed' || status === 'failed' || (!stillPending && status !== 'pending' && status !== 'sending')) { |
| 133 | + const terminalStatus = status && status !== 'unknown' ? status : 'complete' |
| 134 | + updateKeyPending({ ...tx, status: terminalStatus, nextPollAt: 0 }) |
| 135 | + continue |
| 136 | + } |
| 137 | + const pollInterval = nextPollFromBatch(result.batch, tx.pollInterval || MIN_POLL_INTERVAL) |
| 138 | + updateKeyPending({ |
| 139 | + ...tx, |
| 140 | + status: status || 'pending', |
| 141 | + pollInterval, |
| 142 | + nextPollAt: Date.now() + pollInterval * 1000 |
| 143 | + }) |
| 144 | + } catch (error) { |
| 145 | + const pollInterval = Math.max(MIN_POLL_INTERVAL, Math.min(MAX_POLL_INTERVAL, (tx.pollInterval || MIN_POLL_INTERVAL) * 2)) |
| 146 | + updateKeyPending({ |
| 147 | + ...tx, |
| 148 | + status: 'checking', |
| 149 | + pollInterval, |
| 150 | + nextPollAt: Date.now() + pollInterval * 1000, |
| 151 | + lastError: error.message |
| 152 | + }) |
| 153 | + } |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +export const downloadText = (filename, text) => { |
| 158 | + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }) |
| 159 | + const url = window.URL.createObjectURL(blob) |
| 160 | + const a = document.createElement('a') |
| 161 | + a.style.display = 'none' |
| 162 | + a.href = url |
| 163 | + a.download = filename |
| 164 | + document.body.appendChild(a) |
| 165 | + a.click() |
| 166 | + a.remove() |
| 167 | + window.URL.revokeObjectURL(url) |
| 168 | +} |
0 commit comments