The game is easy to hack right now, but the wallet is not, because it use the evernode cluster to protect itself. It includes built-in safeguards for payment amounts, and it coordinates multi-signature requests across all participating instances. These requests are stored inside the decentralized cluster, and once all required signatures are gathered, the transaction is broadcast.
On the backend, I’m using webcbuilder with Node.js. I’ve installed the xrpl library, created a few API endpoints, and set up instance variables to securely handle seeds:
extravar.secret - Each instance has its own unique seed (system tweaked for three instances).
extravar.master - A shared master seed, identical across all instances.
The JavaScript code also includes an array of instance endpoints used for collecting multisigns.
Sidedish used for smart contract functions.
index.html
Code: Select all
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARMY Memory Game</title>
<link rel="stylesheet" href="style.css" />
<style>
/* Modal overlay */
.modal {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal.hidden { display: none; }
.modal-content {
background: #0f1a17;
color: #e9f1ec;
padding: 24px 28px;
border-radius: 14px;
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
max-width: 420px;
width: 100%;
text-align: center;
animation: popIn 0.25s ease;
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 16px;
font-size: 20px;
color: #27d08e;
}
.modal-content form {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.modal-content input[type="text"] {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.06);
color: #e9f1ec;
font-size: 14px;
}
.modal-content input[type="text"]:focus {
outline: none;
border-color: #27d08e;
box-shadow: 0 0 0 2px rgba(39,208,142,.3);
}
.status {
font-size: 14px;
text-align: left;
background: rgba(255,255,255,.04);
padding: 10px;
border-radius: 8px;
max-height: 120px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,.08);
}
@keyframes popIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
</style>
</head>
<body>
<header>
<h1>$ARMY — Memory Match</h1>
<span id="turnsCounter" class="hud">Turns: <strong>0</strong>/4</span>
<button id="resetBtn" class="btn">Shuffle & Restart</button>
</div>
</header>
<!-- Payment Modal -->
<div id="paymentModal" class="modal hidden">
<div class="modal-content">
<h2>Claim Your ARMY Reward</h2>
<form id="paymentForm">
<label for="xrplAddress">Your XRPL Wallet Address:</label>
<input type="text" id="xrplAddress" name="xrplAddress" required />
<button type="submit" class="btn">Send</button>
<button id="restartBtn" class="btn hidden" type="button">Shuffle & Restart</button>
</form>
<div id="signingStatus" class="status"></div>
<div style="display:flex;flex-direction:column;width:100%;justify-content:center;align-items:center;padding-top:25px;padding-bottom:25px;"><p style="font-size:12px;">Sophisticated smart contracts on</p>
<img src="evernode-logo.png" style="max-width:110px;"></div>
</div>
</div>
<main>
<div id="game-board" class="game-board" aria-live="polite"></div>
</main>
<script src="script.js"></script>
<div style="width:100%;display:flex;justify-content:center;align-items:center;padding-bottom:50px;"><footer style="text-align: center; margin-top: 3rem; color: #07112d; font-size: 0.9rem;">
<p style="font-size:10px;color:#FFF;">Decentralized Hosting at...</p>
<div style="margin:auto;">
<a href="https://www.evernode.org/" target="_Blank"><img src="evernode-logo.png" style="max-width:110px;"></a><br>
<a href="https://evernode.forum/viewtopic.php?t=105" style="color:#FFF;text-decoration:underline;">source code</a><br>
<p style="margin-top:25px;font-size:10px;color:#FFF;padding-left:16px;padding-right:16px;text-align:center;">Disclaimer: This is a beta version, I have only just begun learning blockchain devleopment.</p>
</div></footer></div>
</body>
</html>
Code: Select all
const IMAGES = [
"army_01.png","army_02.png","army_03.png","army_04.png","army_05.png",
"army_06.png","army_07.png","army_08.png","army_09.png","army_10.png"
];
const BACK_IMG = "images/card_back.png";
const board = document.getElementById('game-board');
const resetBtn = document.getElementById('resetBtn');
const turnsCounterEl = document.getElementById('turnsCounter');
const MAX_CARD_TURNS = 4;
const input = document.getElementById("xrplAddress");
let cards = [];
let openIndexes = [];
let lockBoard = false;
const signingStatus = document.getElementById('signingStatus');
function appendStatusLine(msg) {
const div = document.createElement("div");
div.textContent = msg;
signingStatus.appendChild(div);
signingStatus.scrollTop = signingStatus.scrollHeight; // auto-scroll
}
const signerUrls = [
"https://thefirstinstance/sign-all-payments",
"https://thesecondinstance/sign-all-payments",
"https://thethirdinstance/sign-all-payments"
];
const paymentModal = document.getElementById('paymentModal');
const paymentForm = document.getElementById('paymentForm');
function updateTurnsHUD(turns) {
if (!turnsCounterEl) return;
const strong = turnsCounterEl.querySelector('strong');
if (strong) strong.textContent = String(turns || 0);
}
resetBtn.addEventListener('click', setup);
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function setup() {
board.innerHTML = '';
cards = [];
openIndexes = [];
lockBoard = false;
updateTurnsHUD(0);
const deck = [];
IMAGES.forEach((img, i) => { deck.push({id:i, img: img}); deck.push({id:i, img: img}); });
shuffle(deck);
deck.forEach((item, idx) => {
const card = createCardEl(item.img, item.id, idx);
board.appendChild(card.el);
cards.push(card);
});
}
paymentForm.addEventListener('submit', async (e) => {
e.preventDefault();
signingStatus.innerHTML = ""; // clear
appendStatusLine("âš¡ Preparing payment...");
const address = document.getElementById('xrplAddress').value.trim();
if (!address) {
appendStatusLine("⌠Please enter a valid address.");
return;
}
try {
let resp = await fetch("/prepare-payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destination: address })
});
let prepared = await resp.json();
if (!prepared.ok) throw new Error("Prepare failed: " + JSON.stringify(prepared));
appendStatusLine("✅ Payment prepared. Waiting for cluster sync...");
await new Promise(r => setTimeout(r, 4000));
for (let i = 0; i < signerUrls.length; i++) {
const url = signerUrls[i];
appendStatusLine(`ðŸ–Šï¸ Evernode instance ${i + 1} signing...`);
let signed = false;
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts && !signed; attempt++) {
appendStatusLine(` Attempt ${attempt}...`);
const controller = new AbortController();
const signal = controller.signal;
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
const res = await fetch(url, { method: "POST", signal });
clearTimeout(timeoutId);
if (!res.ok) {
appendStatusLine(` ⌠(HTTP ${res.status})`);
console.warn(`Signer ${i + 1} attempt ${attempt} HTTP ${res.status}`);
} else {
const data = await res.json();
appendStatusLine(" ✅ Success");
console.log(`Signer ${i + 1} attempt ${attempt} result:`, data);
signed = true;
}
} catch (err) {
clearTimeout(timeoutId);
if (err.name === "AbortError") {
appendStatusLine(" â±ï¸ (timed out)");
} else {
appendStatusLine(" ⌠(failed)");
}
}
}
if (!signed) {
appendStatusLine(`âš ï¸ Evernode instance ${i + 1} failed after ${maxAttempts} attempts.`);
}
await new Promise(r => setTimeout(r, 6000));
}
appendStatusLine("🚀 Submitting payment...");
let final = null;
let paid = false;
const submitMaxAttempts = 3;
for (let attempt = 1; attempt <= submitMaxAttempts && !paid; attempt++) {
appendStatusLine(` Payment attempt ${attempt}...`);
const controller = new AbortController();
const signal = controller.signal;
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const resp = await fetch("/submit-payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ destination: address }),
signal
});
clearTimeout(timeoutId);
if (!resp.ok) {
appendStatusLine(` ⌠(HTTP ${resp.status})`);
if (resp.status === 404) {
appendStatusLine("âš ï¸ No unpaid payment found. Aborting retries.");
break;
}
} else {
final = await resp.json().catch(() => null);
if (final && final.ok) {
appendStatusLine(`✅ Payment successful! Hash: ${final.hash}`);
submitBtn.classList.add("hidden");
restartBtn.classList.remove("hidden");
paid = true;
} else {
const reason =
final?.result?.meta?.TransactionResult ||
final?.result?.engine_result ||
final?.status ||
"Unknown error";
appendStatusLine(`⌠Payment failed: ${reason}`);
break;
}
}
} catch (err) {
clearTimeout(timeoutId);
if (err.name === "AbortError") {
appendStatusLine("â±ï¸ Timed out after 20s");
} else {
appendStatusLine("⌠Failed to submit");
}
}
if (!paid && attempt < submitMaxAttempts) {
await new Promise(r => setTimeout(r, 1000));
}
}
if (!paid) {
if (final) {
appendStatusLine(`⌠Payment failed after ${submitMaxAttempts} attempts.`);
try {
await fetch("/mark-payment-failed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paymentId: final.id,
txResult: final.result
})
});
appendStatusLine("📨 Reported failure to cluster.");
} catch {
appendStatusLine("âš ï¸ Could not report failure to cluster.");
}
} else {
appendStatusLine("⌠Payment failed.");
}
}
} catch (err) {
signingStatus.innerHTML = "";
appendStatusLine("⌠Error: " + err.message);
}
});
function createCardEl(src, pairId, index) {
const el = document.createElement('button');
el.className = 'card';
el.setAttribute('aria-label', 'Memory card');
el.setAttribute('data-index', index);
const inner = document.createElement('div');
inner.className = 'card-inner';
const back = document.createElement('div');
back.className = 'card-face card-back';
const backImg = document.createElement('img');
backImg.src = BACK_IMG;
backImg.alt = 'Card back';
back.appendChild(backImg);
const front = document.createElement('div');
front.className = 'card-face card-front';
const frontImg = document.createElement('img');
frontImg.src = 'images/' + src;
frontImg.alt = 'Card image';
front.appendChild(frontImg);
inner.appendChild(back);
inner.appendChild(front);
el.appendChild(inner);
const cardObj = { el, inner, front, back, pairId, index, matched: false, flipped: false, turns: 0 };
el.addEventListener('click', () => onCardClick(cardObj));
return cardObj;
}
function onCardClick(card) {
if (lockBoard) return;
if (card.matched) return;
if (card.flipped) return;
card.turns += 1;
updateTurnsHUD(card.turns);
card.flipped = true;
card.el.classList.add('is-flipped');
openIndexes.push(card.index);
if (openIndexes.length === 2) {
lockBoard = true;
const [i1, i2] = openIndexes;
const c1 = cards[i1];
const c2 = cards[i2];
if (c1.pairId === c2.pairId) {
c1.matched = c2.matched = true;
c1.el.classList.add('matched');
c2.el.classList.add('matched');
openIndexes = [];
lockBoard = false;
if (cards.every(c => c.matched)) {
setTimeout(() => {
paymentModal.classList.remove('hidden');
}, 300);
}
} else {
const turnsA = c1.turns;
const turnsB = c2.turns;
setTimeout(() => {
const a = cards[i1];
const b = cards[i2];
if (a && !a.matched) { a.flipped = false; a.el.classList.remove('is-flipped'); }
if (b && !b.matched) { b.flipped = false; b.el.classList.remove('is-flipped'); }
openIndexes = [];
lockBoard = false;
if ((turnsA >= MAX_CARD_TURNS) || (turnsB >= MAX_CARD_TURNS)) {
setTimeout(() => {
alert('Out of turns on this card! Try again.');
setup();
}, 50);
}
}, 1000);
}
}
}
setup();
input.addEventListener("input", () => {
const value = input.value.trim();
if (value.startsWith("r") && value.length >= 25) {
input.setCustomValidity("");
} else {
input.setCustomValidity("Must be a real XRPL address (starting with r).");
}
});
const submitBtn = paymentForm.querySelector("button[type='submit']");
const restartBtn = document.getElementById("restartBtn");
restartBtn.addEventListener("click", () => {
paymentModal.classList.add("hidden");
setup(); // reshuffle cards
restartBtn.classList.add("hidden");
submitBtn.classList.remove("hidden");
});
Code: Select all
div#signingStatus::-webkit-scrollbar {
width: 8px;
}
div#signingStatus::-webkit-scrollbar-track {
background: #1c1c1c;
border-radius: 8px;
}
div#signingStatus::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #095604, #0e2400);
border-radius: 8px;
}
div#signingStatus::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #095604, #0e2400);
}
.hidden {
display: none;
}
div#signingStatus {
scrollbar-width: thin;
scrollbar-color: #00ffcc #1c1c1c;
}
:root {
--gap: 14px;
--card-radius: 14px;
--shadow: 0 6px 18px rgba(0,0,0,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #0b1210;
color: #e9f1ec;
}
header {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
padding: 18px;
position: sticky;
top: 0;
background: linear-gradient(180deg, rgba(11,18,16,.96) 0%, rgba(11,18,16,.75) 100%);
backdrop-filter: blur(6px);
z-index: 10;
border-bottom: 1px solid rgba(255,255,255,.08);
}
h1 { margin: 0; font-size: 20px; letter-spacing: .3px; }
.btn {
background: #1f5b41;
color: #e9f1ec;
border: none;
padding: 10px 14px;
border-radius: 10px;
cursor: pointer;
box-shadow: var(--shadow);
}
.btn:hover { filter: brightness(1.08); }
main { max-width: 1100px; margin: 22px auto; padding: 0 16px 38px; }
.game-board {
display: grid;
grid-template-columns: repeat(5, minmax(10px, 1fr));
gap: var(--gap);
}
.card {
background: transparent;
border: 0;
padding: 0;
appearance: none;
-webkit-appearance: none;
aspect-ratio: 1 / 1;
position: relative;
perspective: 1000px;
border-radius: var(--card-radius);
box-shadow: var(--shadow);
cursor: pointer;
outline: none;
display: block;
line-height: 0;
overflow: visible;
}
.card-inner {
width: 100%;
height: 100%;
position: absolute;
transform-style: preserve-3d;
transition: transform 0.3s ease;
border-radius: var(--card-radius);
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
}
.card.is-flipped .card-inner { transform: rotateY(180deg); }
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: var(--card-radius);
overflow: hidden;
}
.card-face img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-back { background: #0f1a17; }
.card-front { transform: rotateY(180deg); background: #0e1714; }
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal.hidden {
display: none;
}
.modal-content {
background: #0f1a17;
color: #e9f1ec;
padding: 24px 28px;
border-radius: 14px;
box-shadow: 0 8px 28px rgba(0,0,0,0.45);
max-width: 420px;
width: 100%;
text-align: center;
animation: popIn 0.25s ease;
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 16px;
font-size: 20px;
color: #27d08e;
}
.modal-content form {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.modal-content label {
font-size: 14px;
text-align: left;
opacity: .85;
}
.modal-content input[type="text"] {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,.15);
background: rgba(255,255,255,.06);
color: #e9f1ec;
font-size: 14px;
}
.modal-content input[type="text"]:focus {
outline: none;
border-color: #27d08e;
box-shadow: 0 0 0 2px rgba(39,208,142,.3);
}
.status {
font-size: 14px;
text-align: left;
background: rgba(255,255,255,.04);
padding: 10px;
border-radius: 8px;
max-height: 120px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,.08);
}
@keyframes popIn {
from { transform: scale(0.9); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.card.matched {
box-shadow: 0 0 0 3px rgba(39,208,142,.9), 0 10px 22px rgba(39,208,142,.25);
border-radius: var(--card-radius);
}
.card.matched .card-front::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--card-radius);
box-shadow: inset 0 0 0 4px #27d08e;
pointer-events: none; /* never block clicks */
}
.hud {
font-size: 14px;
opacity: .9;
padding: 6px 10px;
background: rgba(255,255,255,.06);
border-radius: 8px;
border: 1px solid rgba(255,255,255,.08);
}
.hud strong { color: #27d08e; }
div#signingStatus {overflow-x:hidden;line-break:anywhere;}
post endpoint /mark-payment-failed
Code: Select all
const HotPocket = require("hotpocket-js-client")
const BSON = require("bson")
//const HOTPOCKET_WS_URL = "wss://localhost:30321"
async function createHpClient() {
const userKeyPair = await HotPocket.generateKeys()
const client = await HotPocket.createClient(
[HOTPOCKET_WS_URL],
userKeyPair,
{ protocol: HotPocket.protocols.bson }
)
if (!(await client.connect())) {
throw new Error("Failed to connect to HotPocket cluster")
}
return { client, userKeyPair }
}
try {
const { paymentId, txResult } = req.body
if (!paymentId) {
return res.status(400).json({ error: "Missing paymentId" })
}
const { client: hpClient } = await createHpClient()
const updatePayload = {
type: "update-payment-status",
paymentId,
status: "failed",
txResult: txResult || null,
}
const submission = await hpClient.submitContractInput(BSON.serialize(updatePayload))
const status = await submission.submissionStatus
if (status.status !== "accepted") {
console.error("[mark-payment-failed] Cluster rejected input:", status)
return res.status(500).json({ error: "Cluster rejected input" })
}
console.log("[mark-payment-failed] Sent update-payment-status:", updatePayload)
try { hpClient.close() } catch (e) {}
return res.json({
ok: true,
id: paymentId,
status: "failed",
paid: "failed",
})
} catch (err) {
console.error("Error in /mark-payment-failed:", err)
return res.status(500).json({ error: err.message })
}
Code: Select all
const fs = require("fs")
const xrpl = require("xrpl")
const HotPocket = require("hotpocket-js-client")
const BSON = require("bson")
//const HOTPOCKET_WS_URL = "wss://localhost:30321" You do not need this, because we grab it from webcbuilder.
async function createHpClient() {
const userKeyPair = await HotPocket.generateKeys()
const client = await HotPocket.createClient(
[HOTPOCKET_WS_URL],
userKeyPair,
{ protocol: HotPocket.protocols.bson }
)
if (!(await client.connect())) {
throw new Error("Failed to connect to HotPocket cluster")
}
return { client, userKeyPair }
}
try {
const requiredKeys = ["extravar.master"]
for (const key of requiredKeys) {
if (!extraVars[key]) {
return res.status(500).json({ error: `Missing required config: ${key}` })
}
}
const masterSeed = extraVars["extravar.master"]
const xrplClient = new xrpl.Client("wss://s.altnet.rippletest.net:51233")
await xrplClient.connect()
try {
const sender = xrpl.Wallet.fromSeed(masterSeed)
const { destination, amount, currency, issuer, useXrp } = req.body
const paymentTx = {
TransactionType: "Payment",
Account: sender.address,
Destination: destination || "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
Amount: useXrp
? (amount || "1000000") // XRP in drops
: {
currency: currency || "524C555344000000000000000000000000000000",
issuer: issuer || "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
value: amount || "0.1"
}
}
let prepared = await xrplClient.autofill(paymentTx, { fee_mult_max: 4 })
prepared.Fee = (parseInt(prepared.Fee, 10) * 20).toString()
prepared.LastLedgerSequence = prepared.LastLedgerSequence + 100
const { client: hpClient, userKeyPair } = await createHpClient()
const payload = {
type: "prepared-payment",
requester: userKeyPair.publicKey.toString("hex"),
tx: prepared
}
const submission = await hpClient.submitContractInput(BSON.serialize(payload))
const status = await submission.submissionStatus
if (status.status !== "accepted") {
return res.status(500).json({ error: "Cluster rejected input" })
}
return res.json({ ok: true, submitted: payload })
} finally {
try { await xrplClient.disconnect() } catch (e) {
console.warn("Error disconnecting XRPL client:", e)
}
}
} catch (err) {
console.error("Error in /prepare-payment:", err)
return res.status(500).json({ error: err.message })
}
Code: Select all
const fs = require("fs")
const xrpl = require("xrpl")
const HotPocket = require("hotpocket-js-client")
const BSON = require("bson")
const PAYMENTS_FILE = "/contract/contract_fs/seed/state/payments.json"
//const HOTPOCKET_WS_URL = "wss://localhost:30321" You do not need this, because we grab it from webcbuilder
async function createHpClient() {
const userKeyPair = await HotPocket.generateKeys()
const client = await HotPocket.createClient(
[HOTPOCKET_WS_URL],
userKeyPair,
{ protocol: HotPocket.protocols.bson }
)
if (!(await client.connect())) {
throw new Error("Failed to connect to HotPocket cluster")
}
return { client, userKeyPair }
}
try {
const requiredKeys = ["extravar.secret"]
for (const key of requiredKeys) {
if (!extraVars[key]) {
return res.status(500).json({ error: `Missing required config: ${key}` })
}
}
const secretseed = extraVars["extravar.secret"]
const signer = xrpl.Wallet.fromSeed(secretseed)
if (!fs.existsSync(PAYMENTS_FILE)) {
return res.status(404).json({ error: "No payments.json found" })
}
const payments = JSON.parse(fs.readFileSync(PAYMENTS_FILE, "utf8"))
if (!Array.isArray(payments) || payments.length === 0) {
return res.json({ ok: true, message: "No payments to sign." })
}
const { client: hpClient, userKeyPair } = await createHpClient()
const results = []
try {
for (const payment of payments) {
try {
const alreadySigned = payment.signers?.some(
(s) => s.account === signer.address
)
if (alreadySigned) {
results.push({ id: payment.id, status: "skipped (already signed)" })
continue
}
const signed = signer.sign(payment.tx, true)
if (!signed || !signed.tx_blob) {
results.push({ id: payment.id, status: "signing failed" })
continue
}
const payload = {
type: "add-signature",
paymentId: payment.id,
signer: {
account: signer.address,
tx_blob: signed.tx_blob,
hash: signed.hash,
},
}
const submission = await hpClient.submitContractInput(
BSON.serialize(payload)
)
const status = await submission.submissionStatus
if (status.status !== "accepted") {
results.push({ id: payment.id, status: "rejected by cluster" })
} else {
results.push({ id: payment.id, status: "signed and submitted" })
}
} catch (innerErr) {
console.error("Error signing payment", payment.id, innerErr)
results.push({ id: payment.id, status: "error: " + innerErr.message })
}
}
} finally {
try {
hpClient.close()
} catch {}
}
return res.json({ ok: true, results })
} catch (err) {
console.error("Error in /sign-all-payments:", err)
return res.status(500).json({ error: err.message })
}
Code: Select all
const HotPocket = require("hotpocket-js-client")
const BSON = require("bson")
const xrpl = require("xrpl")
//const HOTPOCKET_WS_URL = "wss://localhost:30321"
// Create a HotPocket client with a fresh keypair
async function createHpClient() {
const userKeyPair = await HotPocket.generateKeys()
const client = await HotPocket.createClient(
[HOTPOCKET_WS_URL],
userKeyPair,
{ protocol: HotPocket.protocols.bson }
)
if (!(await client.connect())) {
throw new Error("Failed to connect to HotPocket cluster")
}
return { client, userKeyPair }
}
// Helper: ask cluster for list-payments once (returns parsed reply)
// (guards removal of listener if removeListener isn't implemented)
async function listPaymentsOnce(hpClient, timeoutMs = 10000) {
const payload = BSON.serialize({ type: "list-payments" })
return new Promise((resolve, reject) => {
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
try {
if (typeof hpClient.removeListener === "function") hpClient.removeListener(HotPocket.events.contractOutput, onOutput)
} catch (e) {}
reject(new Error("Timeout waiting for list-payments (once)"))
}, timeoutMs)
function onOutput(result) {
if (timedOut) return
for (const out of result.outputs) {
try {
const data = BSON.deserialize(out)
if (data && data.type === "list-payments-reply") {
clearTimeout(timeout)
try {
if (typeof hpClient.removeListener === "function") hpClient.removeListener(HotPocket.events.contractOutput, onOutput)
} catch (e) {}
resolve(data)
return
}
} catch (err) {
console.error("[submit-payment] BSON parse error in listPaymentsOnce:", err)
}
}
}
hpClient.on(HotPocket.events.contractOutput, onOutput)
try {
hpClient.submitContractInput(payload)
} catch (err) {
clearTimeout(timeout)
try {
if (typeof hpClient.removeListener === "function") hpClient.removeListener(HotPocket.events.contractOutput, onOutput)
} catch (e) {}
reject(err)
}
})
}
// Helper: wait for an explicit update-payment-status-reply for a paymentId
async function waitForStatusAck(hpClient, paymentId, timeoutMs = 10000) {
return new Promise((resolve) => {
let done = false
const timeout = setTimeout(() => {
if (done) return
done = true
try {
if (typeof hpClient.removeListener === "function") hpClient.removeListener(HotPocket.events.contractOutput, onOutput)
} catch (e) {}
resolve(null) // timed out -> null
}, timeoutMs)
function onOutput(result) {
if (done) return
for (const out of result.outputs) {
try {
const data = BSON.deserialize(out)
if (data && data.type === "update-payment-status-reply" && data.paymentId === paymentId) {
done = true
clearTimeout(timeout)
try {
if (typeof hpClient.removeListener === "function") hpClient.removeListener(HotPocket.events.contractOutput, onOutput)
} catch (e) {}
resolve(data)
return
}
} catch (err) {
console.error("[submit-payment] BSON parse error in waitForStatusAck:", err)
}
}
}
hpClient.on(HotPocket.events.contractOutput, onOutput)
})
}
try {
const { destination } = req.body
if (!destination) {
return res.status(400).json({ error: "Missing destination" })
}
// Create HotPocket client
const { client: hpClient } = await createHpClient()
// Ask cluster for all payments
const payload = BSON.serialize({ type: "list-payments" })
const reply = await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Timeout waiting for list-payments")),
32000
)
function onOutput(result) {
for (const out of result.outputs) {
try {
const data = BSON.deserialize(out)
if (data && data.type === "list-payments-reply") {
clearTimeout(timeout)
try {
if (typeof hpClient.removeListener === "function") hpClient.removeListener(HotPocket.events.contractOutput, onOutput)
} catch (e) {}
resolve(data)
return
}
} catch (err) {
console.error("[submit-payment] BSON parse error:", err)
}
}
}
hpClient.on(HotPocket.events.contractOutput, onOutput)
hpClient.submitContractInput(payload)
})
const allPayments = reply.payments || []
// robust find: accept new `status` field or legacy `paid:false`
const payment = allPayments.find(p => {
if (!p || !p.tx) return false
if (p.tx.Destination !== destination) return false
if ('status' in p) return p.status === 'unpaid'
if ('paid' in p) return p.paid === false || p.paid === 'unpaid'
return false
})
if (!payment) {
try { hpClient.close() } catch (e) {}
return res.status(404).json({
error: "No unpaid payment found for this destination",
destination,
paymentsCount: allPayments.length
})
}
if (!payment.signers || payment.signers.length === 0) {
try { hpClient.close() } catch (e) {}
return res.json({ ok: false, id: payment.id, status: "no signatures available" })
}
// XRPL client
const client = new xrpl.Client("wss://s.altnet.rippletest.net:51233")
await client.connect()
try {
// Multisign and submit
const blobs = payment.signers.map(s => s.tx_blob)
const multisigned = xrpl.multisign(blobs)
let result;
try {
result = await client.submitAndWait(multisigned);
} catch (xrplErr) {
console.warn("[submit-payment] XRPL submission error:", xrplErr);
// Return a clean failure response instead of HTTP 500
return res.json({
ok: false,
id: payment.id,
status: "failed",
confirmed: true,
error: xrplErr.message || String(xrplErr),
});
}
// Normalize result object
const xr = result.result ?? result
let engineResult = xr?.engine_result
?? xr?.meta?.TransactionResult
?? xr?.tx_json?.meta?.TransactionResult
?? xr?.meta?.engine_result
// Fallback scan
if (!engineResult) {
try {
const s = JSON.stringify(xr || {})
if (s.includes("tesSUCCESS")) engineResult = "tesSUCCESS"
} catch (e) {}
}
const success = engineResult === "tesSUCCESS"
const newStatus = success ? "paid" : "failed"
console.log("[submit-payment] detected engine_result ->", engineResult, "=> success=", success)
// ? Return to frontend immediately if XRPL succeeded
if (success) {
// Fire-and-forget HotPocket update
const updatePayload = {
type: "update-payment-status",
paymentId: payment.id,
status: newStatus,
txResult: result.result,
}
try {
hpClient.submitContractInput(BSON.serialize(updatePayload))
console.log("[submit-payment] Sent update-payment-status (non-blocking)", updatePayload)
await new Promise(r => setTimeout(r, 5000))
} catch (err) {
console.error("[submit-payment] Failed to send update-payment-status:", err)
}
return res.json({
ok: true,
id: payment.id,
status: newStatus,
confirmed: true, // always true after XRPL validates
hash: result.result?.tx_json?.hash || result.tx_json?.hash || result.result?.hash || null,
result: result.result,
})
}
// ? If XRPL failed, still return failure
const updatePayload = {
type: "update-payment-status",
paymentId: payment.id,
status: "failed",
txResult: result.result,
};
(async () => {
try {
hpClient.submitContractInput(BSON.serialize(updatePayload));
console.log("[submit-payment] Sent update-payment-status (failed)", updatePayload);
// small delay so hpClient flushes before closing
await new Promise(r => setTimeout(r, 500));
} catch (err) {
console.error("[submit-payment] Failed to send update-payment-status (failed):", err);
}
})(); // fire-and-forget
// Now return failure to frontend immediately
return res.json({
ok: false,
id: payment.id,
status: newStatus,
confirmed: true,
result: result.result,
});
} finally {
try { await client.disconnect() } catch (e) {}
try { hpClient.close() } catch (e) {}
}
} catch (err) {
console.error("Error in /submit-payment:", err)
return res.status(500).json({ error: err && err.message ? err.message : String(err) })
}
Photo:
