From c74460e77e82e6e43d744ec0baa4d2be679198bc Mon Sep 17 00:00:00 2001 From: defi101 <170788970+defi101@users.noreply.github.com> Date: Fri, 8 May 2026 16:25:24 +0700 Subject: [PATCH 1/3] Add private credit score demo dapp --- main.cpp | 37 ++- static/index.html | 4 +- static/private-credit-score.css | 331 +++++++++++++++++++ static/private-credit-score.html | 114 +++++++ static/private-credit-score.js | 391 +++++++++++++++++++++++ static/templates/index.json | 4 + static/templates/private_credit/main.aml | 115 +++++++ 7 files changed, 993 insertions(+), 3 deletions(-) create mode 100644 static/private-credit-score.css create mode 100644 static/private-credit-score.html create mode 100644 static/private-credit-score.js create mode 100644 static/templates/private_credit/main.aml diff --git a/main.cpp b/main.cpp index 972bfe0..3508fa7 100644 --- a/main.cpp +++ b/main.cpp @@ -1880,7 +1880,7 @@ int main(int argc, char** argv) { tx.to_ = contract_addr; tx.amount = "0"; tx.nonce = nonce + 1; - tx.ou = parse_ou(body, "50000000"); + tx.ou = parse_ou(body, "200000"); tx.timestamp = now_ts(); tx.op_type = "deploy"; tx.encrypted_data = bytecode; @@ -2036,6 +2036,39 @@ int main(int argc, char** argv) { res.set_content(r.result.dump(), "application/json"); }); + svr.Post("/api/contract/view", [](const httplib::Request& req, httplib::Response& res) { + WALLET_GUARD + json body; + try { body = json::parse(req.body); } catch (...) { + res.status = 400; + res.set_content(err_json("invalid json").dump(), "application/json"); + return; + } + std::string addr = body.value("address", ""); + std::string method = body.value("method", ""); + if (addr.empty() || method.empty()) { + res.status = 400; + res.set_content(err_json("address and method required").dump(), "application/json"); + return; + } + json params = json::array(); + if (body.contains("params")) { + params = body["params"]; + if (!params.is_array()) { + res.status = 400; + res.set_content(err_json("params must be an array").dump(), "application/json"); + return; + } + } + auto r = g_rpc.contract_call_view(addr, method, params, g_wallet.addr); + if (!r.ok) { + res.status = 400; + res.set_content(err_json(r.error).dump(), "application/json"); + return; + } + res.set_content(r.result.dump(), "application/json"); + }); + svr.Post("/api/fhe/encrypt", [](const httplib::Request& req, httplib::Response& res) { WALLET_GUARD if (!g_pvac_ok) { @@ -2374,4 +2407,4 @@ int main(int argc, char** argv) { printf("octra_wallet listening on http://127.0.0.1:%d\n", port); svr.listen("127.0.0.1", port); return 0; -} \ No newline at end of file +} diff --git a/static/index.html b/static/index.html index abbed67..96b6869 100644 --- a/static/index.html +++ b/static/index.html @@ -285,6 +285,7 @@
apps
+
@@ -344,6 +345,7 @@ +
@@ -550,4 +552,4 @@ - \ No newline at end of file + diff --git a/static/private-credit-score.css b/static/private-credit-score.css new file mode 100644 index 0000000..1868be9 --- /dev/null +++ b/static/private-credit-score.css @@ -0,0 +1,331 @@ +* { box-sizing: border-box; } + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + font-family: Tahoma, Arial, sans-serif; + background: #f4f6f8; + color: #1f2933; + font-size: 13px; + letter-spacing: 0; +} + +button, +input, +textarea { + font: inherit; +} + +button, +.ghost-link { + min-height: 34px; + border: 1px solid #9aa8b5; + background: #eef2f5; + color: #24394d; + padding: 0 12px; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +button:hover, +.ghost-link:hover { + background: #dde5ec; +} + +button:disabled { + cursor: not-allowed; + color: #8a97a3; + background: #f2f4f6; + border-color: #d0d7de; +} + +.primary { + background: #315f7d; + border-color: #315f7d; + color: #fff; +} + +.primary:hover { + background: #244c66; +} + +.small-btn { + min-height: 26px; + padding: 0 8px; +} + +.shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 20px 0 28px; +} + +.topbar { + min-height: 68px; + border: 1px solid #d0d7de; + background: #fff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 14px 16px; +} + +h1 { + margin: 0; + font-size: 19px; + line-height: 1.1; + font-weight: 700; + color: #24394d; +} + +.subline { + margin-top: 7px; + color: #637381; + display: flex; + gap: 10px; + flex-wrap: wrap; + word-break: break-all; +} + +.top-actions, +.button-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.layout { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(420px, 1.3fr); + gap: 14px; + margin-top: 14px; +} + +.panel { + background: #fff; + border: 1px solid #d0d7de; + min-width: 0; +} + +.result-panel { + grid-column: 1 / -1; +} + +.panel-title { + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid #d0d7de; + background: #eef2f5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #24394d; + font-weight: 700; +} + +.field-row { + display: grid; + gap: 6px; + padding: 12px 14px 0; +} + +label { + color: #52616f; + font-size: 12px; +} + +input, +textarea { + width: 100%; + border: 1px solid #c7d0d9; + background: #fff; + color: #1f2933; + min-height: 34px; + padding: 7px 9px; + border-radius: 4px; +} + +input:focus, +textarea:focus { + outline: 2px solid #8bb8d8; + outline-offset: 0; +} + +.button-row { + padding: 14px; +} + +.grid-form { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 0; +} + +.log-box, +.progress-list { + margin: 0 14px 14px; + min-height: 42px; + border: 1px solid #d0d7de; + background: #f8fafc; + color: #52616f; + padding: 10px; + overflow-wrap: anywhere; +} + +.score-display { + display: grid; + grid-template-columns: repeat(2, minmax(180px, 1fr)); + border-bottom: 1px solid #d0d7de; +} + +.score-display > div { + padding: 18px 20px; + border-right: 1px solid #d0d7de; +} + +.score-display > div:last-child { + border-right: 0; +} + +.score-label { + display: block; + color: #637381; + font-size: 12px; + margin-bottom: 4px; +} + +.score-display strong { + display: block; + font-size: 28px; + line-height: 1.1; + color: #24394d; + word-break: break-word; +} + +.pill { + border: 1px solid #b8c3cc; + color: #52616f; + background: #fff; + border-radius: 999px; + padding: 3px 8px; + font-size: 12px; + font-weight: 400; +} + +.pill.ok { + border-color: #4b8b68; + color: #276749; + background: #edf7f1; +} + +.pill.warn { + border-color: #c49a3a; + color: #7a5a00; + background: #fff8e6; +} + +.pill.err { + border-color: #c75d5d; + color: #8a2d2d; + background: #fff0f0; +} + +.muted { + color: #637381; +} + +.progress-item { + padding: 5px 0; + border-bottom: 1px solid #e5e9ef; +} + +.progress-item:last-child { + border-bottom: 0; +} + +.progress-item.ok { + color: #276749; +} + +.progress-item.err { + color: #8a2d2d; +} + +.details-box { + margin: 0 14px 14px; + border: 1px solid #d0d7de; +} + +.details-box summary { + cursor: pointer; + padding: 10px; + background: #f8fafc; + color: #52616f; +} + +#cipher-output { + border: 0; + border-top: 1px solid #d0d7de; + min-height: 110px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + word-break: break-all; +} + +@media (max-width: 860px) { + .shell { + width: 100%; + padding: 0; + } + + .topbar { + border-left: 0; + border-right: 0; + align-items: flex-start; + flex-direction: column; + } + + .layout { + grid-template-columns: 1fr; + gap: 0; + margin-top: 0; + } + + .panel { + border-left: 0; + border-right: 0; + border-top: 0; + } + + .grid-form { + grid-template-columns: repeat(2, minmax(130px, 1fr)); + } +} + +@media (max-width: 520px) { + .grid-form, + .score-display { + grid-template-columns: 1fr; + } + + .score-display > div { + border-right: 0; + border-bottom: 1px solid #d0d7de; + } + + .score-display > div:last-child { + border-bottom: 0; + } +} diff --git a/static/private-credit-score.html b/static/private-credit-score.html new file mode 100644 index 0000000..0e1a2a2 --- /dev/null +++ b/static/private-credit-score.html @@ -0,0 +1,114 @@ + + + + + +private credit score + + + +
+
+
+

private credit score

+
checking wallet
+
+
+ faucet + wallet + +
+
+ +
+
+
+ contract + not set +
+
+ + +
+
+ + + +
+
+
+ +
+
+ applicant + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ +
+
+ result + idle +
+
+
+ score + - +
+
+ tier + - +
+
+
+
ready
+
+
+ ciphertext + +
+
+
+
+ + + diff --git a/static/private-credit-score.js b/static/private-credit-score.js new file mode 100644 index 0000000..a16b894 --- /dev/null +++ b/static/private-credit-score.js @@ -0,0 +1,391 @@ +(function () { + var FIELD_NAMES = [ + 'age', + 'income_k', + 'debt_k', + 'savings_k', + 'history_score', + 'employment_years', + 'defaults_count', + 'open_accounts' + ]; + var CONTRACT_KEY = 'octra_private_credit_contract'; + var compiledBytecode = ''; + var contractSource = ''; + var walletAddress = ''; + var latestCiphertext = ''; + var publicBalanceRaw = 0; + var fees = {}; + + function el(id) { + return document.getElementById(id); + } + + function setPill(id, text, cls) { + var node = el(id); + node.textContent = text; + node.className = 'pill ' + (cls || 'muted'); + } + + function setLog(id, text, cls) { + var node = el(id); + node.textContent = text || ''; + node.className = 'log-box' + (cls ? ' ' + cls : ''); + } + + function progress(items) { + el('progress-list').innerHTML = items.map(function (item) { + var cls = item.cls ? ' ' + item.cls : ''; + return '
' + escapeHtml(item.text) + '
'; + }).join(''); + } + + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + async function api(method, path, body) { + var opts = { method: method, headers: {} }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + var res = await fetch('/api' + path, opts); + var text = await res.text(); + var json = {}; + if (text) { + try { + json = JSON.parse(text); + } catch (e) { + throw new Error('bad response'); + } + } + if (!res.ok) { + throw new Error(json.error || json.message || 'request failed'); + } + return json; + } + + async function loadSource() { + if (contractSource) return contractSource; + var res = await fetch('templates/private_credit/main.aml?v=1'); + contractSource = await res.text(); + return contractSource; + } + + async function refreshWallet() { + try { + var status = await api('GET', '/wallet/status'); + if (!status.loaded) { + walletAddress = ''; + publicBalanceRaw = 0; + el('wallet-state').textContent = 'wallet locked'; + el('wallet-address').textContent = ''; + el('wallet-balance').textContent = ''; + setPill('run-state', 'locked', 'warn'); + return; + } + var wallet = await api('GET', '/wallet'); + walletAddress = wallet.address || ''; + el('wallet-state').textContent = 'wallet ready'; + el('wallet-address').textContent = walletAddress; + await Promise.all([refreshBalance(), refreshFees()]); + setPill('run-state', 'idle', 'muted'); + } catch (err) { + walletAddress = ''; + publicBalanceRaw = 0; + el('wallet-state').textContent = 'wallet unavailable'; + el('wallet-address').textContent = ''; + el('wallet-balance').textContent = ''; + setPill('run-state', 'offline', 'err'); + } + } + + async function refreshBalance() { + var balance = await api('GET', '/balance'); + publicBalanceRaw = Number(balance.public_balance || 0); + el('wallet-balance').textContent = 'balance ' + formatOct(publicBalanceRaw) + ' OCT'; + } + + async function refreshFees() { + fees = await api('GET', '/fee'); + } + + function feeFor(op, fallback) { + var fee = fees[op] || {}; + return String(fee.recommended || fee.base_fee || fallback); + } + + function formatOct(raw) { + var value = Number(raw || 0) / 1000000; + return value.toLocaleString(undefined, { maximumFractionDigits: 6 }); + } + + function assertBalanceFor(rawFee) { + var fee = Number(rawFee || 0); + if (Number.isFinite(fee) && publicBalanceRaw < fee) { + throw new Error('insufficient balance: need ' + formatOct(fee) + ' OCT fee, current balance is ' + formatOct(publicBalanceRaw) + ' OCT. Use the faucet first.'); + } + } + + function loadStoredContract() { + var stored = localStorage.getItem(CONTRACT_KEY) || ''; + el('contract-address').value = stored; + updateContractState(); + } + + function updateContractState() { + var address = getContractAddress(); + if (address) { + setPill('contract-state', 'ready', 'ok'); + } else { + setPill('contract-state', 'not set', 'muted'); + } + } + + function saveContractAddress() { + var address = getContractAddress(); + localStorage.setItem(CONTRACT_KEY, address); + updateContractState(); + setLog('contract-output', address ? 'saved ' + address : 'cleared'); + } + + function getContractAddress() { + return el('contract-address').value.trim(); + } + + async function compileContract() { + setPill('contract-state', 'compiling', 'warn'); + setLog('contract-output', 'compiling...'); + var source = await loadSource(); + var result = await api('POST', '/contract/compile-aml', { source: source }); + compiledBytecode = result.bytecode || ''; + setPill('contract-state', 'compiled', 'ok'); + setLog('contract-output', 'compiled: ' + result.instructions + ' instructions, ' + result.size + ' bytes'); + return result; + } + + async function deployContract() { + if (!walletAddress) await refreshWallet(); + if (!walletAddress) throw new Error('wallet locked'); + await Promise.all([refreshBalance(), refreshFees()]); + var deployFee = feeFor('deploy', '200000'); + assertBalanceFor(deployFee); + if (!compiledBytecode) await compileContract(); + + setPill('contract-state', 'deploying', 'warn'); + setLog('contract-output', 'deploying with fee ' + formatOct(deployFee) + ' OCT...'); + var source = await loadSource(); + var result = await api('POST', '/contract/deploy', { + bytecode: compiledBytecode, + params: '[]', + source: source, + ou: deployFee + }); + if (!result.contract_address) throw new Error(result.error || 'deploy failed'); + el('contract-address').value = result.contract_address; + localStorage.setItem(CONTRACT_KEY, result.contract_address); + setPill('contract-state', 'deployed', 'ok'); + setLog('contract-output', 'deployed ' + result.contract_address + (result.tx_hash ? ' / ' + result.tx_hash : '')); + } + + function readInputs() { + var values = {}; + FIELD_NAMES.forEach(function (name) { + var input = el(name); + var value = Number(input.value); + if (!Number.isFinite(value)) { + throw new Error(name + ' invalid'); + } + var min = input.min === '' ? -Infinity : Number(input.min); + var max = input.max === '' ? Infinity : Number(input.max); + if (value < min || value > max) { + throw new Error(name + ' out of range'); + } + values[name] = Math.trunc(value); + }); + return values; + } + + function plainScore(values) { + return 520 + - values.age + + values.income_k + - values.debt_k * 2 + + values.savings_k * 2 + + values.history_score * 2 + + values.employment_years * 8 + - values.defaults_count * 140 + - values.open_accounts * 3; + } + + function tierFor(score) { + if (score >= 720) return 'excellent'; + if (score >= 660) return 'good'; + if (score >= 600) return 'fair'; + return 'high risk'; + } + + async function encryptValues(values) { + var out = []; + for (var i = 0; i < FIELD_NAMES.length; i++) { + var name = FIELD_NAMES[i]; + progress([{ text: 'encrypting ' + name + ' (' + (i + 1) + '/' + FIELD_NAMES.length + ')', cls: '' }]); + var encrypted = await api('POST', '/fhe/encrypt', { value: values[name] }); + if (!encrypted.ciphertext) throw new Error('encrypt failed: ' + name); + out.push(encrypted.ciphertext); + } + return out; + } + + async function runPrivateScore() { + var contract = getContractAddress(); + if (!contract) throw new Error('contract address required'); + if (!walletAddress) await refreshWallet(); + if (!walletAddress) throw new Error('wallet locked'); + await ensureContractExists(contract); + + var values = readInputs(); + var localScore = plainScore(values); + setPill('run-state', 'encrypting', 'warn'); + progress([{ text: 'local estimate: ' + localScore, cls: '' }]); + + var ciphertexts = await encryptValues(values); + setPill('run-state', 'calling', 'warn'); + progress([{ text: 'calling private_score', cls: '' }]); + + var params = [walletAddress].concat(ciphertexts); + var view = await api('POST', '/contract/view', { + address: contract, + method: 'private_score', + params: params + }); + var ctScore = view.result || view.value || ''; + if (!ctScore) throw new Error('empty contract result'); + latestCiphertext = ctScore; + el('cipher-output').value = ctScore; + + setPill('run-state', 'decrypting', 'warn'); + progress([{ text: 'decrypting result', cls: '' }]); + var decrypted = await api('POST', '/fhe/decrypt', { ciphertext: ctScore }); + var score = Number(decrypted.value); + if (!Number.isFinite(score)) throw new Error('decrypt failed'); + + el('score-value').textContent = String(score); + el('risk-tier').textContent = tierFor(score); + el('store-score').disabled = false; + setPill('run-state', 'complete', 'ok'); + progress([ + { text: 'encrypted inputs submitted to view call', cls: 'ok' }, + { text: 'score decrypted locally: ' + score, cls: 'ok' }, + { text: 'tier: ' + tierFor(score), cls: 'ok' } + ]); + } + + async function storeScore() { + var contract = getContractAddress(); + if (!contract) throw new Error('contract address required'); + if (!latestCiphertext) throw new Error('run score first'); + await Promise.all([refreshBalance(), refreshFees()]); + var callFee = feeFor('call', '1000'); + assertBalanceFor(callFee); + setPill('run-state', 'storing', 'warn'); + var result = await api('POST', '/contract/call', { + address: contract, + method: 'store_score', + params: [latestCiphertext], + ou: callFee + }); + setPill('run-state', 'stored', 'ok'); + progress([{ text: 'store_score tx: ' + (result.tx_hash || 'submitted'), cls: 'ok' }]); + } + + async function loadLatest() { + var contract = getContractAddress(); + if (!contract) throw new Error('contract address required'); + if (!walletAddress) await refreshWallet(); + if (!walletAddress) throw new Error('wallet locked'); + await ensureContractExists(contract); + setPill('run-state', 'loading', 'warn'); + var view = await api('POST', '/contract/view', { + address: contract, + method: 'latest_score', + params: [walletAddress] + }); + var ctScore = view.result || view.value || ''; + if (!ctScore || ctScore === '0') throw new Error('no stored score'); + latestCiphertext = ctScore; + el('cipher-output').value = ctScore; + var decrypted = await api('POST', '/fhe/decrypt', { ciphertext: ctScore }); + var score = Number(decrypted.value); + el('score-value').textContent = String(score); + el('risk-tier').textContent = tierFor(score); + el('store-score').disabled = false; + setPill('run-state', 'loaded', 'ok'); + progress([{ text: 'latest encrypted score loaded', cls: 'ok' }]); + } + + function loadSample() { + var sample = { + age: 34, + income_k: 115, + debt_k: 42, + savings_k: 38, + history_score: 82, + employment_years: 7, + defaults_count: 0, + open_accounts: 5 + }; + FIELD_NAMES.forEach(function (name) { + el(name).value = sample[name]; + }); + } + + async function ensureContractExists(contract) { + try { + await api('GET', '/contract/info?address=' + encodeURIComponent(contract)); + } catch (err) { + throw new Error('contract not deployed or wrong address. Deploy it first, then use the deployed contract address.'); + } + } + + function wire(id, fn) { + el(id).addEventListener('click', function () { + Promise.resolve() + .then(fn) + .catch(function (err) { + var contractAction = id.indexOf('contract') >= 0 || id === 'deploy-contract' || id === 'save-contract'; + setPill(contractAction ? 'contract-state' : 'run-state', 'error', 'err'); + if (contractAction) { + setLog('contract-output', err.message || String(err)); + } else { + progress([{ text: err.message || String(err), cls: 'err' }]); + } + }); + }); + } + + function init() { + loadStoredContract(); + refreshWallet(); + loadSource().catch(function () { + setLog('contract-output', 'contract source unavailable'); + }); + wire('refresh-wallet', refreshWallet); + wire('save-contract', saveContractAddress); + wire('compile-contract', compileContract); + wire('deploy-contract', deployContract); + wire('score-private', runPrivateScore); + wire('store-score', storeScore); + wire('load-latest', loadLatest); + wire('load-sample', loadSample); + el('contract-address').addEventListener('input', updateContractState); + } + + init(); +})(); diff --git a/static/templates/index.json b/static/templates/index.json index 8f71377..21c7517 100644 --- a/static/templates/index.json +++ b/static/templates/index.json @@ -22,5 +22,9 @@ "multisig": { "name": "Multisig", "files": ["main.aml"] + }, + "private_credit": { + "name": "Private Credit Score", + "files": ["main.aml"] } } diff --git a/static/templates/private_credit/main.aml b/static/templates/private_credit/main.aml new file mode 100644 index 0000000..2835b5b --- /dev/null +++ b/static/templates/private_credit/main.aml @@ -0,0 +1,115 @@ +contract PrivateCreditScore { + const BASE_SCORE: int = 520 + const EXCELLENT_SCORE: int = 720 + const GOOD_SCORE: int = 660 + const FAIR_SCORE: int = 600 + + state { + owner: address + model_version: int + total_private_scores: int + encrypted_scores: map[address]string + score_epochs: map[address]int + } + + event PrivateScoreStored(user: address, epoch_num: int) + event ModelVersionChanged(version: int) + + constructor() { + self.owner = origin + self.model_version = 1 + self.total_private_scores = 0 + } + + view fn plain_score(age: int, income_k: int, debt_k: int, savings_k: int, history_score: int, employment_years: int, defaults_count: int, open_accounts: int): int { + let score = BASE_SCORE + score -= age + score += income_k + score -= debt_k * 2 + score += savings_k * 2 + score += history_score * 2 + score += employment_years * 8 + score -= defaults_count * 140 + score -= open_accounts * 3 + return score + } + + view fn private_score(pk_addr: string, ct_age: string, ct_income_k: string, ct_debt_k: string, ct_savings_k: string, ct_history_score: string, ct_employment_years: string, ct_defaults_count: string, ct_open_accounts: string): string { + let pk = fhe_load_pk(pk_addr) + let age = fhe_deser(ct_age) + let income_k = fhe_deser(ct_income_k) + let debt_k = fhe_deser(ct_debt_k) + let savings_k = fhe_deser(ct_savings_k) + let history_score = fhe_deser(ct_history_score) + let employment_years = fhe_deser(ct_employment_years) + let defaults_count = fhe_deser(ct_defaults_count) + let open_accounts = fhe_deser(ct_open_accounts) + + let score = fhe_add_const(pk, fhe_scale(pk, age, -1), BASE_SCORE) + score = fhe_add(pk, score, fhe_scale(pk, income_k, 1)) + score = fhe_add(pk, score, fhe_scale(pk, debt_k, -2)) + score = fhe_add(pk, score, fhe_scale(pk, savings_k, 2)) + score = fhe_add(pk, score, fhe_scale(pk, history_score, 2)) + score = fhe_add(pk, score, fhe_scale(pk, employment_years, 8)) + score = fhe_add(pk, score, fhe_scale(pk, defaults_count, -140)) + score = fhe_add(pk, score, fhe_scale(pk, open_accounts, -3)) + return fhe_ser(score) + } + + fn store_score(ct_score: string): bool { + require(len(ct_score) > 0, "score required") + self.encrypted_scores[caller] = ct_score + self.score_epochs[caller] = epoch + self.total_private_scores += 1 + emit PrivateScoreStored(caller, epoch) + return true + } + + fn set_model_version(version: int): bool { + require(caller == self.owner, "not owner") + require(version > self.model_version, "version must increase") + self.model_version = version + emit ModelVersionChanged(version) + return true + } + + view fn latest_score(addr: address): string { + return self.encrypted_scores[addr] + } + + view fn latest_score_epoch(addr: address): int { + return self.score_epochs[addr] + } + + view fn risk_tier(score: int): string { + if score >= EXCELLENT_SCORE { + return "excellent" + } + if score >= GOOD_SCORE { + return "good" + } + if score >= FAIR_SCORE { + return "fair" + } + return "high_risk" + } + + view fn get_model_info(): string { + let s = to_string(self.model_version) + s = concat(s, ",") + s = concat(s, to_string(BASE_SCORE)) + s = concat(s, ",") + s = concat(s, to_string(EXCELLENT_SCORE)) + s = concat(s, ",") + s = concat(s, to_string(GOOD_SCORE)) + s = concat(s, ",") + s = concat(s, to_string(FAIR_SCORE)) + s = concat(s, ",") + s = concat(s, to_string(self.total_private_scores)) + return s + } + + view fn get_owner(): address { + return self.owner + } +} From 37f0f52d7dcdb213e319127e05d72856ed643674 Mon Sep 17 00:00:00 2001 From: defi101 <170788970+defi101@users.noreply.github.com> Date: Fri, 8 May 2026 16:52:22 +0700 Subject: [PATCH 2/3] Force bridge EVM transactions to Ethereum mainnet --- static/bridge.html | 111 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 8 deletions(-) diff --git a/static/bridge.html b/static/bridge.html index a02b9aa..91abee3 100644 --- a/static/bridge.html +++ b/static/bridge.html @@ -36,6 +36,9 @@ .chain-arrow{width:40px;display:flex;align-items:center;justify-content:center;font-size:16px;color:#8C9DB6;flex-shrink:0} .notice-bar{padding:10px 20px;background:#F7F8FA;border-bottom:1px solid #E5E9EF;font-size:11px;color:#8C9DB6;line-height:170%} .notice-bar .disclaimer{color:#B0B8C8;font-size:10px;margin-top:4px} +.network-note{margin-top:8px;padding:8px;background:#FFF8E1;border:1px solid #FFE082;color:#795548;font-size:10px;line-height:150%} +.network-note.ok{background:#F1F8E9;border-color:#C8E6C9;color:#4CAF50} +.network-note.err{background:#FFEBEE;border-color:#FFCDD2;color:#C62828} .bridge-body{padding:16px 20px} .input-group{margin-bottom:14px} .input-group label{display:block;font-size:11px;color:#8C9DB6;letter-spacing:0.5px;margin-bottom:4px} @@ -155,6 +158,7 @@

octra bridge

lock OCT on octra, receive wOCT on ethereum
+
Network note: choose Ethereum Mainnet (ETH) in MetaMask only. Bridge transactions are blocked on every other EVM network.
@@ -248,7 +252,8 @@

connect wallet

var ETH_BRIDGE = '0xE7eD69b852fd2a1406080B26A37e8E04e7dA4caE'; var SIGNER_URL = '/api/bridge/signer'; var RECOVERY_URL = 'https://relayer-002838819188.octra.network/recovery.json'; -var SEPOLIA_CHAIN_ID = '0xaa36a7'; +var ETH_CHAIN_ID = '0x1'; +var ETH_CHAIN_NAME = 'Ethereum Mainnet'; var OCT_DECIMALS = 6; var _dir = 'o2e'; @@ -257,6 +262,7 @@

connect wallet

var _octBalance = '0'; var _woctBalance = '0'; var _ethProvider = null; +var _ethNetworkOk = false; async function wcli(method, path, body) { var opts = { method: method, headers: {} }; @@ -330,6 +336,7 @@

connect wallet

try { var accounts = await _ethProvider.request({ method: 'eth_requestAccounts' }); if (!accounts.length) return; + await requireEthNetwork(); _ethAddr = accounts[0]; $('eth-addr').textContent = _ethAddr.substring(0, 8) + '...' + _ethAddr.slice(-4); $('eth-addr').classList.remove('none'); @@ -351,15 +358,82 @@

connect wallet

try { recoveryFetch(true); } catch(e) {} } }); + if (_ethProvider.on) { + _ethProvider.on('chainChanged', function(chainId) { + setEthNetworkState(isEthMainnet(chainId), chainId); + if (!isEthMainnet(chainId)) showStatus('err', 'wrong MetaMask network. Select Ethereum Mainnet (ETH); bridge transactions are blocked on this network.'); + refreshBalances(); + validateForm(); + }); + } } catch(e) { showStatus('err', w.name + ': ' + e.message); } } +function isEthMainnet(chainId) { + return (chainId || '').toLowerCase() === ETH_CHAIN_ID; +} + +function setEthNetworkState(ok, chainId) { + _ethNetworkOk = !!ok; + var note = $('network-note'); + if (note) { + note.className = 'network-note ' + (ok ? 'ok' : (chainId ? 'err' : '')); + note.textContent = ok + ? 'Network: Ethereum Mainnet (ETH). Bridge commands will be sent only on this network.' + : 'Network note: choose Ethereum Mainnet (ETH) in MetaMask only. Bridge transactions are blocked on every other EVM network.'; + } +} + +async function checkEthNetwork(silent) { + if (!_ethProvider) return false; + try { + var chainId = await _ethProvider.request({ method: 'eth_chainId' }); + var ok = isEthMainnet(chainId); + setEthNetworkState(ok, chainId); + if (!ok && !silent) showStatus('err', 'select Ethereum Mainnet (ETH) in MetaMask. Current network is blocked.'); + return ok; + } catch(e) { + setEthNetworkState(false, ''); + if (!silent) showStatus('err', 'cannot read MetaMask network'); + return false; + } +} + +async function requireEthNetwork() { + if (!_ethProvider) throw new Error('connect MetaMask first'); + if (await checkEthNetwork(true)) return true; + try { + showStatus('info', 'switching MetaMask to Ethereum Mainnet (ETH)...'); + await _ethProvider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: ETH_CHAIN_ID }] }); + } catch(e) { + if (e && e.code === 4902) { + await _ethProvider.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: ETH_CHAIN_ID, + chainName: ETH_CHAIN_NAME, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: ['https://ethereum.publicnode.com'], + blockExplorerUrls: ['https://etherscan.io'] + }] + }); + } else { + throw new Error('select Ethereum Mainnet (ETH) in MetaMask. Bridge transactions are blocked on other networks.'); + } + } + if (!(await checkEthNetwork(true))) { + throw new Error('select Ethereum Mainnet (ETH) in MetaMask. Bridge transactions are blocked on other networks.'); + } + return true; +} + async function refreshBalances() { if (_octraAddr) { try { var b = await wcli('GET', '/balance'); _octBalance = b.public_balance || b.balance_raw || '0'; $('bal-oct').textContent = fmtU(_octBalance, OCT_DECIMALS); } catch(e) {} } if (_ethAddr && WOCT_ADDR) { try { + if (!(await checkEthNetwork(true))) { _woctBalance = '0'; $('bal-woct').textContent = 'switch to ETH'; validateForm(); return; } var data = '0x70a08231000000000000000000000000' + _ethAddr.substring(2); var result = await _ethProvider.request({ method: 'eth_call', params: [{ to: WOCT_ADDR, data: data }, 'latest'] }); _woctBalance = (!result || result === '0x' || result === '0x0') ? '0' : BigInt(result).toString(); @@ -402,6 +476,7 @@

connect wallet

var amt = parseFloat(amtStr); $('output-val').textContent = (amtStr && !isNaN(amt) && amt > 0) ? addCommas(amtStr) : '0'; if (!_octraAddr || !_ethAddr) { btn.disabled = true; btn.textContent = 'connect both wallets'; return; } + if (!_ethNetworkOk) { btn.disabled = true; btn.textContent = 'switch to ETH'; return; } if (!amtStr || isNaN(amt) || amt <= 0) { btn.disabled = true; btn.textContent = 'enter amount'; return; } var rawAmt = parseU(amtStr, OCT_DECIMALS); if (_dir === 'o2e') { @@ -431,7 +506,7 @@

connect wallet

$('cm-from').textContent = _octraAddr.substring(0, 14) + '...'; $('cm-to').textContent = recip; $('cm-receive').textContent = amt + ' wOCT'; - $('cm-warning').textContent = 'OCT will be locked on octra. wOCT will be minted on ethereum.'; + $('cm-warning').textContent = 'OCT will be locked on octra. wOCT will be minted on Ethereum Mainnet only. Select Ethereum Mainnet (ETH) in MetaMask before claiming.'; $('cm-confirm-btn').textContent = 'lock & bridge'; } else { $('cm-title').textContent = 'confirm: burn wOCT'; @@ -440,7 +515,7 @@

connect wallet

$('cm-from').textContent = _ethAddr.substring(0, 10) + '...'; $('cm-to').textContent = recip; $('cm-receive').textContent = amt + ' OCT'; - $('cm-warning').textContent = 'wOCT will be burned on ethereum. OCT will be unlocked on octra (~2 min).'; + $('cm-warning').textContent = 'wOCT will be burned on Ethereum Mainnet only. Do not approve or sign this bridge on any other EVM network.'; $('cm-confirm-btn').textContent = 'burn & bridge'; } $('confirm-modal').classList.add('show'); @@ -450,6 +525,7 @@

connect wallet

async function confirmBridge() { closeModal(); + try { await requireEthNetwork(); } catch(e) { showStatus('err', e.message); return; } if (_dir === 'o2e') await doForward(); else await doReverse(); } @@ -478,6 +554,7 @@

connect wallet

]); setStep('lock', 'active'); try { + await requireEthNetwork(); var r = await wcli('POST', '/contract/call', { address: BRIDGE_VAULT, method: 'lock_to_eth', params: [recip], amount: rawAmt, ou: '1000' }); @@ -523,6 +600,7 @@

connect wallet

var lastSimErr = ''; while (simAttempts < simMaxAttempts) { try { + await requireEthNetwork(); await _ethProvider.request({ method: 'eth_call', params: [{ from: _ethAddr, to: ETH_BRIDGE, data: claimData.calldata }, 'latest'] @@ -568,6 +646,7 @@

connect wallet

var interval = 3000; while (Date.now() - start < timeoutMs) { try { + if (!(await checkEthNetwork(true))) { await new Promise(function(r) { setTimeout(r, interval); }); continue; } var receipt = await _ethProvider.request({ method: 'eth_getTransactionReceipt', params: [txHash] @@ -585,6 +664,7 @@

connect wallet

var floor = 10000000000n; var priority = 2000000000n; try { + await requireEthNetwork(); var gasPriceHex = await _ethProvider.request({method: 'eth_gasPrice'}); var current = BigInt(gasPriceHex); var doubled = current * 2n; @@ -608,10 +688,12 @@

connect wallet

var explorerBase = (typeof ETH_EXPLORER !== 'undefined' && ETH_EXPLORER) ? ETH_EXPLORER : 'https://etherscan.io'; var claimTx = null; try { + await requireEthNetwork(); var c = _pendingClaim; if (claimBtn) { claimBtn.textContent = 'verifying...'; } try { + await requireEthNetwork(); await _ethProvider.request({ method: 'eth_call', params: [{ from: _ethAddr, to: ETH_BRIDGE, data: c.calldata }, 'latest'] @@ -624,9 +706,10 @@

connect wallet

if (claimBtn) { claimBtn.textContent = 'submitting to metamask...'; } var gas = await getSafeGas(); + await requireEthNetwork(); claimTx = await _ethProvider.request({ method: 'eth_sendTransaction', - params: [{ from: _ethAddr, to: ETH_BRIDGE, data: c.calldata, gas: '0x60000', maxFeePerGas: gas.maxFeePerGas, maxPriorityFeePerGas: gas.maxPriorityFeePerGas }] + params: [{ from: _ethAddr, to: ETH_BRIDGE, data: c.calldata, gas: '0x60000', chainId: ETH_CHAIN_ID, maxFeePerGas: gas.maxFeePerGas, maxPriorityFeePerGas: gas.maxPriorityFeePerGas }] }); } catch(e) { if (claimBtn) { claimBtn.disabled = false; claimBtn.textContent = 'claim on ethereum (retry)'; } @@ -793,6 +876,7 @@

connect wallet

async function historyCheckE2o(entry) { if (!entry.burn_tx_hash) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } if (!_ethProvider) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } + if (!(await checkEthNetwork(true))) { historyUpdate(entry.id, {last_checked:Date.now(), last_error:'switch MetaMask to Ethereum Mainnet (ETH)'}); return; } try { var r = await _ethProvider.request({method:'eth_getTransactionReceipt', params:[entry.burn_tx_hash]}); if (!r) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } @@ -842,6 +926,10 @@

connect wallet

var claimData = await buildClaimCalldata(entry.epoch, entry.recipient, entry.amount_raw, data.result); if (!claimData) { historyUpdate(entry.id, {last_checked:Date.now(), last_error:'no claim calldata'}); return; } if (_ethProvider && _ethAddr) { + if (!(await checkEthNetwork(true))) { + historyUpdate(entry.id, {claim_data:claimData, last_checked:Date.now(), last_error:'switch MetaMask to Ethereum Mainnet (ETH)'}); + return; + } try { await _ethProvider.request({method:'eth_call', params:[{from:_ethAddr, to:ETH_BRIDGE, data:claimData.calldata}, 'latest']}); historyUpdate(entry.id, {status:'claimable', claim_data:claimData, last_checked:Date.now(), last_error:null}); @@ -923,7 +1011,9 @@

connect wallet

historyUpdate(id, {status:'claiming'}); showStatus('info', 'submitting claim: ' + entry.amt_display + ' wOCT ep ' + entry.epoch); try { + await requireEthNetwork(); try { + await requireEthNetwork(); await _ethProvider.request({method:'eth_call', params:[{from:_ethAddr, to:ETH_BRIDGE, data:entry.claim_data.calldata}, 'latest']}); } catch(simErr) { var em = (simErr && (simErr.message || String(simErr))) || ''; @@ -941,7 +1031,8 @@

connect wallet

return; } var gasFees = await getSafeGas(); - var txReq = {from:_ethAddr, to:ETH_BRIDGE, data:entry.claim_data.calldata}; + await requireEthNetwork(); + var txReq = {from:_ethAddr, to:ETH_BRIDGE, data:entry.claim_data.calldata, chainId:ETH_CHAIN_ID}; for (var k in gasFees) txReq[k] = gasFees[k]; var txHash = await _ethProvider.request({method:'eth_sendTransaction', params:[txReq]}); historyUpdate(id, {claim_tx_hash:txHash}); @@ -1150,11 +1241,13 @@

connect wallet

setStep('approve', 'active'); var burnHistoryId = null; try { + await requireEthNetwork(); var gas1 = await getSafeGas(); var approveData = '0x095ea7b3' + ETH_BRIDGE.substring(2).toLowerCase().padStart(64, '0') + BigInt(rawAmt).toString(16).padStart(64, '0'); + await requireEthNetwork(); var approveTx = await _ethProvider.request({ method: 'eth_sendTransaction', - params: [{ from: _ethAddr, to: WOCT_ADDR, data: approveData, gas: '0x30000', maxFeePerGas: gas1.maxFeePerGas, maxPriorityFeePerGas: gas1.maxPriorityFeePerGas }] + params: [{ from: _ethAddr, to: WOCT_ADDR, data: approveData, gas: '0x30000', chainId: ETH_CHAIN_ID, maxFeePerGas: gas1.maxFeePerGas, maxPriorityFeePerGas: gas1.maxPriorityFeePerGas }] }); var approveReceipt = await waitForReceipt(approveTx, 300000); if (!approveReceipt || approveReceipt.status !== '0x1') { @@ -1169,9 +1262,10 @@

connect wallet

var burnSig = '0xe3e3aed0'; var encoded = abiEncodeStringUint(recip, rawAmt); var burnData = burnSig + encoded; + await requireEthNetwork(); var burnTx = await _ethProvider.request({ method: 'eth_sendTransaction', - params: [{ from: _ethAddr, to: ETH_BRIDGE, data: burnData, gas: '0x40000', maxFeePerGas: gas2.maxFeePerGas, maxPriorityFeePerGas: gas2.maxPriorityFeePerGas }] + params: [{ from: _ethAddr, to: ETH_BRIDGE, data: burnData, gas: '0x40000', chainId: ETH_CHAIN_ID, maxFeePerGas: gas2.maxFeePerGas, maxPriorityFeePerGas: gas2.maxPriorityFeePerGas }] }); burnHistoryId = 'burn_' + burnTx.slice(2, 10) + '_' + Date.now(); historyAdd({ @@ -1261,6 +1355,7 @@

connect wallet

async function getWoctBalance() { if (!_ethAddr || !_ethProvider || !WOCT_ADDR) return '0'; try { + if (!(await checkEthNetwork(true))) return '0'; var data = '0x70a08231000000000000000000000000' + _ethAddr.substring(2); var result = await _ethProvider.request({ method: 'eth_call', params: [{ to: WOCT_ADDR, data: data }, 'latest'] }); if (!result || result === '0x' || result === '0x0') return '0'; @@ -1302,4 +1397,4 @@

connect wallet

}, 300); - \ No newline at end of file + From 036b561cf30e77b96f5f41fd6b81386cdd784016 Mon Sep 17 00:00:00 2001 From: defi101 <170788970+defi101@users.noreply.github.com> Date: Fri, 8 May 2026 17:04:56 +0700 Subject: [PATCH 3/3] Recover bridge claims signed on wrong EVM network --- static/bridge.html | 117 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/static/bridge.html b/static/bridge.html index 91abee3..f8cb5e9 100644 --- a/static/bridge.html +++ b/static/bridge.html @@ -98,10 +98,13 @@ .history-list .hs-item .hs-amount{font-weight:600;color:#2B3A52} .history-list .hs-item .hs-to{font-family:monospace;font-size:10px;color:#516E9A} .history-list .hs-item .hs-time{color:#8C9DB6;font-size:10px;margin-top:1px} +.history-list .hs-item .hs-error{color:#E57373;font-size:10px;margin-top:2px;line-height:140%} .history-list .hs-item .hs-status{font-size:10px;padding:3px 8px;border:1px solid #D0D7E2;white-space:nowrap;font-weight:500;min-width:110px;box-sizing:border-box;text-align:center;display:inline-block} .history-list .hs-item .hs-status.pending{color:#8C9DB6;border-color:#E5E9EF} .history-list .hs-item .hs-status.claimable{color:#fff;background:#4CAF50;border-color:#4CAF50;cursor:pointer} .history-list .hs-item .hs-status.claimable:hover{background:#43A047} +.history-list .hs-item .hs-status.wrong{color:#fff;background:#E57373;border-color:#E57373;cursor:pointer} +.history-list .hs-item .hs-status.wrong:hover{background:#D75C5C} .history-list .hs-item .hs-status.claimed{color:#4CAF50;border-color:#C8E6C9;background:#F1F8E9} .history-list .hs-item .hs-status.expired{color:#E57373;border-color:#FFCDD2} .history-list .hs-item .hs-status.failed{color:#E57373;border-color:#FFCDD2} @@ -201,8 +204,9 @@

octra bridge

refresh status + repair wrong network recover from network - clear old (>24h) + clear completed (>24h)
@@ -255,6 +259,7 @@

connect wallet

var ETH_CHAIN_ID = '0x1'; var ETH_CHAIN_NAME = 'Ethereum Mainnet'; var OCT_DECIMALS = 6; +var BRIDGE_ACTIVE_TTL_MS = 30 * 24 * 60 * 60 * 1000; var _dir = 'o2e'; var _octraAddr = ''; @@ -807,7 +812,8 @@

connect wallet

var labels = { pending_header: 'waiting', claimable: 'claim', claiming: 'claiming...', claimed: 'claimed', expired: 'expired', failed: 'failed', - burning: 'burning', burn_pending: 'unlocking...', unlocked: 'unlocked' + burning: 'burning', burn_pending: 'unlocking...', unlocked: 'unlocked', + wrong_network: 'claim on ETH' }; var visible = _historyShowAll ? arr : arr.slice(0, _historyPageSize); for (var i = 0; i < visible.length; i++) { @@ -818,6 +824,7 @@

connect wallet

var toShort = e.recipient ? (e.recipient.substr(0, 6) + '...' + e.recipient.substr(-4)) : ''; var cls = 'pending'; if (e.status === 'claimable') cls = 'claimable'; + else if (e.status === 'wrong_network') cls = 'wrong'; else if (e.status === 'claimed' || e.status === 'unlocked') cls = 'claimed'; else if (e.status === 'expired') cls = 'expired'; else if (e.status === 'failed') cls = 'failed'; @@ -834,17 +841,21 @@

connect wallet

} else { var lockLink = e.lock_tx_hash ? ('' + hh + ':' + mm + '') : (hh + ':' + mm); var claimInfo = e.claim_tx_hash ? (' | view') : ''; + if (!claimInfo && e.wrong_network_claim_tx) claimInfo = ' | wrong net ' + e.wrong_network_claim_tx.slice(0, 10) + '...'; var epTag = e.epoch ? (' | ep ' + e.epoch) : ' | ep pending'; midLine = '
' + lockLink + epTag + claimInfo + '
'; } + var errLine = e.last_error ? '
' + esc(e.last_error) + '
' : ''; var row = document.createElement('div'); row.className = 'hs-item'; row.innerHTML = - '
' + topLine + midLine + '
' + + '
' + topLine + midLine + errLine + '
' + '
' + (labels[e.status] || e.status) + '
'; var statusEl = row.querySelector('.hs-status'); if (e.status === 'claimable') { statusEl.onclick = (function(id) { return function() { historyClaim(id); }; })(e.id); + } else if (e.status === 'wrong_network') { + statusEl.onclick = (function(id) { return function() { historyRetryWrongNetwork(id); }; })(e.id); } list.appendChild(row); } @@ -873,6 +884,81 @@

connect wallet

historyRender(); } +async function historyCheckClaimTxOnEth(entry) { + if (!entry || entry.direction === 'e2o' || !entry.claim_tx_hash || !_ethProvider) return false; + if (!(await checkEthNetwork(true))) { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:'switch MetaMask to Ethereum Mainnet (ETH) to verify this claim'}); + return false; + } + try { + var r = await _ethProvider.request({method:'eth_getTransactionReceipt', params:[entry.claim_tx_hash]}); + if (r && r.status === '0x1') { + historyUpdate(entry.id, {status:'claimed', last_checked:Date.now(), last_error:null}); + return false; + } + if (!r && entry.status === 'claiming' && entry.claim_submitted_at && Date.now() - entry.claim_submitted_at < 600000) { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:'claim tx submitted on Ethereum Mainnet, waiting for receipt'}); + return false; + } + var wrongHash = entry.claim_tx_hash; + var nextStatus = entry.claim_data ? 'wrong_network' : 'pending_header'; + historyUpdate(entry.id, { + status: nextStatus, + claim_tx_hash: '', + wrong_network_claim_tx: wrongHash, + last_checked: Date.now(), + last_error: r && r.status === '0x0' + ? 'Ethereum claim reverted. Refresh, then claim again on Ethereum Mainnet.' + : 'Previous claim tx is not on Ethereum Mainnet. It was likely signed on another EVM network; claim again on ETH.' + }); + return true; + } catch(e) { + historyUpdate(entry.id, {last_checked:Date.now(), last_error:(e.message || 'claim verification failed')}); + return false; + } +} + +async function historyRetryWrongNetwork(id) { + var entry = historyGet(id); + if (!entry) return; + if (!entry.claim_data) { + showStatus('info', 'refreshing claim data before retry...'); + await historyCheckOne(entry); + entry = historyGet(id); + } + if (entry && entry.claim_data) { + historyUpdate(id, {status:'claimable', last_error:null}); + await historyClaim(id); + } else { + showStatus('err', 'claim data is not ready yet. Click refresh status or recover from network.'); + } +} + +async function historyRepairWrongNetwork(silent) { + if (!_ethProvider || !_ethAddr) { + if (!silent) showStatus('err', 'connect MetaMask first, then repair wrong-network claims'); + return 0; + } + try { + await requireEthNetwork(); + } catch(e) { + if (!silent) showStatus('err', e.message); + return 0; + } + var arr = historyLoad(); + var reopened = 0; + for (var i = 0; i < arr.length; i++) { + var e = arr[i]; + if (e.direction === 'e2o') continue; + if ((e.status === 'claimed' || e.status === 'claiming') && e.claim_tx_hash) { + if (await historyCheckClaimTxOnEth(e)) reopened += 1; + } + } + if (reopened > 0) await historyCheckAll(); + if (!silent) showStatus(reopened > 0 ? 'ok' : 'info', reopened > 0 ? ('reopened ' + reopened + ' wrong-network claim' + (reopened === 1 ? '' : 's') + '. Claim again on ETH.') : 'no wrong-network claims found'); + return reopened; +} + async function historyCheckE2o(entry) { if (!entry.burn_tx_hash) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } if (!_ethProvider) { historyUpdate(entry.id, {last_checked:Date.now()}); return; } @@ -895,8 +981,17 @@

connect wallet

} async function historyCheckOne(entry) { - if (entry.status === 'claimed' || entry.status === 'claiming' || entry.status === 'unlocked') return; - if (Date.now() - entry.locked_at > 86400000) { + if ((entry.status === 'claimed' || entry.status === 'claiming') && entry.claim_tx_hash && entry.direction !== 'e2o') { + var reopened = await historyCheckClaimTxOnEth(entry); + if (!reopened) return; + entry = historyGet(entry.id) || entry; + } + if (entry.status === 'claimed' || entry.status === 'unlocked') return; + if (entry.status === 'claiming' && !entry.claim_tx_hash) { + historyUpdate(entry.id, {status:'claimable', last_error:'claim was interrupted before tx submission; retry on Ethereum Mainnet'}); + return; + } + if (Date.now() - entry.locked_at > BRIDGE_ACTIVE_TTL_MS) { if (entry.status !== 'expired') historyUpdate(entry.id, {status:'expired', last_checked:Date.now()}); return; } @@ -965,7 +1060,11 @@

connect wallet

var arr = historyLoad(); for (var i = 0; i < arr.length; i++) { var e = arr[i]; - if (e.status === 'claimed' || e.status === 'expired' || e.status === 'claiming' || e.status === 'unlocked') continue; + if (e.status === 'claimed' && e.claim_tx_hash && e.direction !== 'e2o') { + await historyCheckOne(e); + continue; + } + if (e.status === 'expired' || e.status === 'unlocked') continue; await historyCheckOne(e); } } @@ -996,11 +1095,13 @@

connect wallet

async function historyRefreshAll() { showStatus('info', 'refreshing history...'); + await historyRepairWrongNetwork(true); await historyCheckAll(); var arr = historyLoad(); var pending = arr.filter(function(e){return e.status==='pending_header';}).length; var claimable = arr.filter(function(e){return e.status==='claimable';}).length; - showStatus('info', 'history refreshed: ' + claimable + ' claimable, ' + pending + ' waiting'); + var wrong = arr.filter(function(e){return e.status==='wrong_network';}).length; + showStatus('info', 'history refreshed: ' + (claimable + wrong) + ' claimable, ' + pending + ' waiting'); } async function historyClaim(id) { @@ -1035,7 +1136,7 @@

connect wallet

var txReq = {from:_ethAddr, to:ETH_BRIDGE, data:entry.claim_data.calldata, chainId:ETH_CHAIN_ID}; for (var k in gasFees) txReq[k] = gasFees[k]; var txHash = await _ethProvider.request({method:'eth_sendTransaction', params:[txReq]}); - historyUpdate(id, {claim_tx_hash:txHash}); + historyUpdate(id, {claim_tx_hash:txHash, claim_submitted_at:Date.now(), last_error:null}); showStatus('info', 'tx submitted: ' + txHash.slice(0,10) + '...'); var receipt = await waitForReceipt(txHash, 300000); if (!receipt) {