Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Check JavaScript syntax
run: find frontend -type f -name '*.js' -print0 | xargs -0 -n1 node --check

- name: Run unit and regression tests
run: node --test tests/pulse-rewards.test.mjs

- name: Reject tracked secrets and deployment outputs
shell: bash
run: |
Expand All @@ -45,6 +48,7 @@ jobs:

contracts:
name: Soroban contracts
if: hashFiles('contracts/**') != ''
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
Expand Down
9 changes: 9 additions & 0 deletions frontend/contracts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"network": "testnet",
"contracts": {
"market": "CAPCAPWPGPOCENAJFYYIE22WYNFEDVZ3CT73M5MAKILFMBQ5TN2MIS6T",
"token": "CBYUQUXPGWUQRV7STCV3YPVLWNTFJHKLEAG7LVAOK7H4FIFJGZW5P476",
"referral": "CCKVUVYXR6FBB4VFYGDF3IDDUVBRJGKPDDRABTZYKI2LKAJNVLF3TTQ2",
"leaderboard": "CCMNYMUI4XMDBTTMM7E6KNQFF3OVKS3Q2ERJ4EVQGCLW4VQCGUGG2AQM"
}
}
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ <h1>Trade your view of <span>what happens next.</span></h1>
<div class="wallet-panel-icon"><svg><use href="#i-wallet" /></svg></div>
<div class="wallet-panel-copy"><strong id="wallet-panel-title">Wallet not connected</strong><span id="wallet-panel-detail">Connect Freighter on Stellar testnet</span></div>
<button type="button" id="wallet-panel-action">Connect</button>
<button type="button" id="wallet-claim-rewards" hidden>Claim rewards</button>
</div>
<fieldset><legend>Choose an outcome</legend><div class="outcome-buttons"><button type="button" class="outcome-button yes-choice active" data-outcome="yes"><span>Yes</span><strong id="yes-price">54%</strong></button><button type="button" class="outcome-button no-choice" data-outcome="no"><span>No</span><strong id="no-price">46%</strong></button></div></fieldset>
<label class="amount-label" for="stake-amount"><span>Stake amount</span><span>Demo balance: 1,000 XLM</span></label>
Expand Down
2 changes: 1 addition & 1 deletion frontend/product.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

122 changes: 115 additions & 7 deletions frontend/script.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { placeBet } from "./soroban.js";
import { placeBet, checkTransactionStatus } from "./soroban.js";

const HORIZON_URL = "https://horizon.stellar.org";
const COINGECKO_URL = "https://api.coingecko.com/api/v3";
Expand All @@ -24,12 +24,14 @@ function loadPositions() {
if (!position || typeof position !== "object") return false;
const validExplorer = !position.explorerUrl
|| (typeof position.explorerUrl === "string" && position.explorerUrl.startsWith(TESTNET_EXPLORER_PREFIX));
const validStatus = !position.status || ["pending", "confirmed", "failed"].includes(position.status);
return marketTitles.has(position.title)
&& ["yes", "no"].includes(position.outcome)
&& /^\d{1,4}(\.\d{1,2})?$/.test(position.stake)
&& /^\d{1,8}(\.\d{1,2})?$/.test(position.returns)
&& typeof position.time === "string"
&& /^[0-9: APMapm.]{1,20}$/.test(position.time)
&& validStatus
&& validExplorer;
}).slice(0, 20);
} catch {
Expand Down Expand Up @@ -184,9 +186,16 @@ function selectMarket(index, scroll = true) {
function renderPositions() {
if (!state.positions.length) return;
$("#position-list").innerHTML = state.positions.map((position) => {
const result = position.explorerUrl
? `<a class="position-result onchain" href="${position.explorerUrl}" target="_blank" rel="noreferrer">Confirmed <svg><use href="#i-external" /></svg></a>`
: '<span class="position-result">Simulated</span>';
let result;
if (position.status === "pending") {
result = `<a class="position-result pending" href="${position.explorerUrl || '#'}" target="_blank" rel="noreferrer">Pending <svg><use href="#i-external" /></svg></a>`;
} else if (position.status === "failed") {
result = '<span class="position-result failed">Failed</span>';
} else if (position.explorerUrl) {
result = `<a class="position-result onchain" href="${position.explorerUrl}" target="_blank" rel="noreferrer">Confirmed <svg><use href="#i-external" /></svg></a>`;
} else {
result = '<span class="position-result">Simulated</span>';
}
return `<article class="position-card"><div><h3>${position.title}</h3><p>Created ${position.time}</p></div><div class="position-stat"><span>Outcome</span><strong class="${position.outcome}">${position.outcome.toUpperCase()}</strong></div><div class="position-stat"><span>Stake</span><strong>${position.stake} XLM</strong></div><div class="position-stat"><span>Potential return</span><strong>${position.returns} XLM</strong></div>${result}</article>`;
}).join("");
}
Expand All @@ -207,6 +216,38 @@ window.showWalletNotice = (message, isError = false) => {
showToast(message, isError);
};

const inFlightMarkets = new Set();

function reconcilePendingPosition(position) {
if (!position?.hash || position.status !== "pending") return;
let attempts = 0;
const pollTimer = setInterval(async () => {
attempts += 1;
if (attempts > 30 || position.status !== "pending") {
clearInterval(pollTimer);
return;
}
try {
const res = await checkTransactionStatus(position.hash);
if (res?.status === "SUCCESS") {
position.status = "confirmed";
savePositions();
renderPositions();
clearInterval(pollTimer);
window.stellarWallet?.refreshBalance().catch(() => {});
showToast("Pending position confirmed on Stellar Testnet!");
} else if (res?.status === "FAILED") {
position.status = "failed";
savePositions();
renderPositions();
clearInterval(pollTimer);
}
} catch {
// transient network poll error
}
}, 2000);
}

document.querySelectorAll("[data-filter]").forEach((button) => button.addEventListener("click", () => {
document.querySelectorAll("[data-filter]").forEach((item) => item.classList.remove("active"));
button.classList.add("active"); renderMarkets(button.dataset.filter);
Expand All @@ -231,7 +272,13 @@ $("#order-form").addEventListener("submit", async (event) => {
const stake = Math.max(1, Math.min(1000, Number($("#stake-amount").value) || 1));
const market = currentMarket();
const probability = state.outcome === "yes" ? market.yes : 100 - market.yes;
const position = { title: market.title, outcome: state.outcome, stake: stake.toFixed(0), returns: (stake / (probability / 100)).toFixed(2), time: new Intl.DateTimeFormat("en", { hour: "2-digit", minute: "2-digit" }).format(new Date()) };
const position = {
title: market.title,
outcome: state.outcome,
stake: stake.toFixed(0),
returns: (stake / (probability / 100)).toFixed(2),
time: new Intl.DateTimeFormat("en", { hour: "2-digit", minute: "2-digit" }).format(new Date()),
};

if (!market.onchainId) {
state.positions.unshift(position);
Expand All @@ -242,6 +289,11 @@ $("#order-form").addEventListener("submit", async (event) => {
return;
}

if (inFlightMarkets.has(market.onchainId)) {
window.showWalletNotice("A transaction is already in flight for this market. Please wait for confirmation.", true);
return;
}

const wallet = window.stellarWallet;
const walletState = wallet?.getState();
if (!walletState?.address) {
Expand All @@ -262,6 +314,10 @@ $("#order-form").addEventListener("submit", async (event) => {
const label = submit.querySelector("span");
const originalLabel = label.textContent;
submit.disabled = true;
inFlightMarkets.add(market.onchainId);

let pendingPosition = null;

try {
const transaction = await placeBet({
address: walletState.address,
Expand All @@ -270,16 +326,66 @@ $("#order-form").addEventListener("submit", async (event) => {
amountXlm: String(stake),
signTransaction: wallet.signTransaction,
onStatus: (status) => { label.textContent = status; },
onSubmitted: ({ hash, explorerUrl }) => {
pendingPosition = {
...position,
status: "pending",
hash,
explorerUrl,
marketId: market.onchainId,
};
state.positions.unshift(pendingPosition);
savePositions();
renderPositions();
showToast("Transaction submitted to Testnet. Waiting for confirmation...");
},
});
state.positions.unshift({ ...position, explorerUrl: transaction.explorerUrl, hash: transaction.hash });

if (pendingPosition) {
pendingPosition.status = "confirmed";
pendingPosition.hash = transaction.hash;
pendingPosition.explorerUrl = transaction.explorerUrl;
} else {
state.positions.unshift({
...position,
status: "confirmed",
explorerUrl: transaction.explorerUrl,
hash: transaction.hash,
marketId: market.onchainId,
});
}
savePositions();
renderPositions();
await wallet.refreshBalance();
showToast("Position confirmed on Stellar Testnet.");
$("#activity").scrollIntoView({ behavior: "smooth" });
} catch (error) {
window.showWalletNotice(error.message || "The Testnet position could not be submitted.", true);
if (error?.name === "TransactionTimeoutError" || error?.explorerUrl) {
const explorerUrl = error.explorerUrl || pendingPosition?.explorerUrl;
const hash = error.hash || pendingPosition?.hash;
if (pendingPosition) {
pendingPosition.status = "pending";
pendingPosition.hash = hash;
pendingPosition.explorerUrl = explorerUrl;
savePositions();
renderPositions();
reconcilePendingPosition(pendingPosition);
}
window.showWalletNotice("Transaction is still confirming on Stellar Testnet. It is recorded as pending in your positions.", false);
$("#activity").scrollIntoView({ behavior: "smooth" });
} else {
if (pendingPosition) {
const idx = state.positions.indexOf(pendingPosition);
if (idx !== -1) {
state.positions.splice(idx, 1);
savePositions();
renderPositions();
}
}
window.showWalletNotice(error?.message || "The Testnet position could not be submitted.", true);
}
} finally {
inFlightMarkets.delete(market.onchainId);
submit.disabled = false;
label.textContent = originalLabel;
}
Expand Down Expand Up @@ -320,7 +426,9 @@ $("#year").textContent = new Date().getFullYear();
renderMarkets();
selectMarket(0, false);
renderPositions();
state.positions.filter((p) => p.status === "pending" && p.hash).forEach(reconcilePendingPosition);
updateNetwork();
updatePrice();
setInterval(updateNetwork, 10000);
setInterval(updatePrice, 60000);

Loading