Add reserved handle pricing, quote management, and tenant DB hardening.

Reserved handles can carry an optional preset price that overrides tenant pricing strategy; handles without a price are quoted as unavailable. Adds handles.price migration, tenant-handles UI, quote delete API, automatic quote retention (KEEP_QUOTES_FOR_DAYS), and resilient tenant schema application for invalid DB files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-15 01:00:05 -04:00
co-authored by Cursor
parent ef4ff5fb85
commit a08087b072
7 changed files with 883 additions and 88 deletions
+48 -5
View File
@@ -94,6 +94,7 @@ const CREATE_HANDLES_TABLE = `
handle TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'reserved' CHECK(status IN ('reserved','available','staged','committed','parked','published','unpublished')),
script_pubkey TEXT,
price INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
@@ -116,6 +117,8 @@ const CREATE_PAYMENTS_TABLE = `
const PAYMENTS_OPTIONAL_COLUMNS = [{ name: 'purchase_id', sql: 'purchase_id INTEGER' }];
const HANDLES_OPTIONAL_COLUMNS = [{ name: 'price', sql: 'price INTEGER' }];
const COUPONS_OPTIONAL_COLUMNS = [
{ name: 'completely_free', sql: 'completely_free INTEGER NOT NULL DEFAULT 0' },
{ name: 'affiliate_split_enabled', sql: 'affiliate_split_enabled INTEGER NOT NULL DEFAULT 0' },
@@ -282,6 +285,11 @@ function runTenantDDL(tenantDb, callback) {
callback(e9);
return;
}
addMissingColumns(tenantDb, 'handles', HANDLES_OPTIONAL_COLUMNS, (e9b) => {
if (e9b) {
callback(e9b);
return;
}
tenantDb.run(CREATE_PAYMENTS_TABLE, (e10) => {
if (e10) {
callback(e10);
@@ -289,6 +297,7 @@ function runTenantDDL(tenantDb, callback) {
}
addMissingColumns(tenantDb, 'payments', PAYMENTS_OPTIONAL_COLUMNS, callback);
});
});
});
});
});
@@ -306,27 +315,60 @@ function runTenantDDL(tenantDb, callback) {
/**
* Apply canonical tenant DDL to every *.db under data/spaces (used by migration 001).
*/
function isSqliteDatabaseFile(dbPath) {
try {
const stat = fs.statSync(dbPath);
if (!stat.isFile() || stat.size < 16) {
return false;
}
const fd = fs.openSync(dbPath, 'r');
const header = Buffer.alloc(16);
fs.readSync(fd, header, 0, 16, 0);
fs.closeSync(fd);
return header.toString('utf8', 0, 15) === 'SQLite format 3';
} catch (_err) {
return false;
}
}
function applyTenantSchemaToAllFiles(spacesDir) {
return new Promise((resolve, reject) => {
if (!fs.existsSync(spacesDir)) {
resolve();
resolve({ processed: 0, skipped: 0 });
return;
}
const files = fs.readdirSync(spacesDir).filter((f) => f.endsWith('.db'));
if (files.length === 0) {
resolve();
resolve({ processed: 0, skipped: 0 });
return;
}
let idx = 0;
let processed = 0;
let skipped = 0;
function processNext() {
if (idx >= files.length) {
resolve();
if (skipped > 0) {
console.warn(
`[tenantSchema] applyTenantSchemaToAllFiles: processed ${processed}, skipped ${skipped} invalid file(s) in ${spacesDir}`
);
}
resolve({ processed, skipped });
return;
}
const fileName = files[idx++];
const dbPath = path.join(spacesDir, fileName);
if (!isSqliteDatabaseFile(dbPath)) {
skipped++;
console.warn(`[tenantSchema] Skipping non-SQLite file: ${dbPath}`);
processNext();
return;
}
const dbPath = path.join(spacesDir, files[idx++]);
const tenantDb = new sqlite3.Database(dbPath, (openErr) => {
if (openErr) {
reject(openErr);
skipped++;
console.warn(`[tenantSchema] Skipping unreadable database ${dbPath}: ${openErr.message}`);
processNext();
return;
}
runTenantDDL(tenantDb, (runErr) => {
@@ -339,6 +381,7 @@ function applyTenantSchemaToAllFiles(spacesDir) {
reject(runErr);
return;
}
processed++;
processNext();
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* Adds optional price column to per-tenant handles table.
* Idempotent: addMissingColumns in runTenantDDL handles re-runs safely.
*/
const path = require('path');
const { applyTenantSchemaToAllFiles } = require('../lib/tenantSchema');
module.exports = {
up: function (db, callback) {
const spacesDir = path.join(__dirname, '..', 'data', 'spaces');
applyTenantSchemaToAllFiles(spacesDir)
.then(() => callback(null))
.catch(callback);
},
down: function (db, callback) {
callback(null);
},
};
+118 -7
View File
@@ -464,9 +464,23 @@
transition: all 0.3s ease;
}
.nav-btn:hover {
background: rgba(255,255,255,0.35);
border-color: white;
.price-input {
width: 90px;
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 0.85em;
}
.price-unavailable {
color: #c0392b;
font-size: 0.82em;
font-style: italic;
}
.price-set {
font-weight: 600;
color: #2c3e50;
}
</style>
</head>
@@ -485,13 +499,14 @@
<h2>Upload Reserved Handles (CSV)</h2>
</div>
<p style="color:var(--text-secondary);margin-top:0;font-size:0.95em;">
Upload a plain-text CSV file with one handle name per line. All handles will be imported with <strong>reserved</strong> status.
Upload a plain-text CSV with one handle per line. Use <code>handle</code> or <code>handle,price</code> (price in sats).
All imported handles get <strong>reserved</strong> status. Reserved handles without a price cannot be purchased.
</p>
<div class="upload-area" id="upload-area">
<input type="file" id="csv-file-input" accept=".csv,.txt" />
<div class="upload-icon">📂</div>
<p>Drag &amp; drop a CSV file here, or click to browse</p>
<p class="file-hint">One handle name per line &nbsp;&nbsp; .csv or .txt</p>
<p class="file-hint">handle per line, or handle,price &nbsp;&nbsp; .csv or .txt</p>
</div>
<div class="upload-actions">
<span class="selected-file" id="selected-file-name"></span>
@@ -531,6 +546,10 @@
<option value="unpublished">unpublished</option>
</select>
</div>
<div id="new-handle-price-wrap">
<label style="font-size:0.85em;color:var(--text-secondary);display:block;margin-bottom:4px;">Price (sats, optional)</label>
<input type="number" id="new-handle-price" min="0" step="1" placeholder="e.g. 50000" style="padding:8px 12px;border:2px solid var(--border-color);border-radius:6px;font-size:0.9em;width:140px;" />
</div>
<div>
<label style="font-size:0.85em;color:var(--text-secondary);display:block;margin-bottom:4px;">Script Pubkey (optional)</label>
<input type="text" id="new-handle-notes" placeholder="5120..." style="padding:8px 12px;border:2px solid var(--border-color);border-radius:6px;font-size:0.9em;width:220px;" />
@@ -576,6 +595,8 @@
checkPlatformMode();
loadHandles();
setupFileInput();
document.getElementById('new-handle-status').addEventListener('change', syncAddFormPriceVisibility);
syncAddFormPriceVisibility();
});
function getSpaceName() {
@@ -699,6 +720,7 @@
<tr id="row-${h.id}">
<td><span class="handle-name">${escHtml(h.handle)}</span></td>
<td><span class="status-badge status-${h.status}">${h.status}</span></td>
<td>${renderPriceCell(h)}</td>
<td style="color:var(--text-secondary);font-size:0.85em;font-family:'Courier New',monospace;" title="${escHtml(h.script_pubkey || '')}">${escHtml(truncatePubkey(h.script_pubkey))}</td>
<td style="color:var(--text-secondary);font-size:0.8em;">${formatDate(h.created_at)}</td>
<td>
@@ -720,6 +742,7 @@
<tr>
<th>Handle</th>
<th>Status</th>
<th>Price (sats)</th>
<th>Script Pubkey</th>
<th>Created</th>
<th>Actions</th>
@@ -730,6 +753,36 @@
`;
}
function renderPriceCell(h) {
if (h.status !== 'reserved') {
return '<span style="color:#aaa;">—</span>';
}
const priceVal = h.price != null && h.price !== '' ? String(h.price) : '';
if (priceVal) {
return `
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<input type="number" class="price-input" id="price-input-${h.id}" min="0" step="1" value="${escAttr(priceVal)}" />
<button class="btn-success" onclick="savePrice(${h.id})">Save</button>
<button class="btn-secondary" style="padding:4px 10px;font-size:0.8em;" onclick="clearPrice(${h.id})">Clear</button>
</div>`;
}
return `
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
<span class="price-unavailable">unavailable</span>
<input type="number" class="price-input" id="price-input-${h.id}" min="0" step="1" placeholder="sats" />
<button class="btn-success" onclick="savePrice(${h.id})">Set</button>
</div>`;
}
function syncAddFormPriceVisibility() {
const status = document.getElementById('new-handle-status').value;
const wrap = document.getElementById('new-handle-price-wrap');
wrap.style.display = status === 'reserved' ? 'block' : 'none';
if (status !== 'reserved') {
document.getElementById('new-handle-price').value = '';
}
}
function renderPagination() {
const totalPages = Math.ceil(filteredHandles.length / PAGE_SIZE);
const el = document.getElementById('pagination');
@@ -810,31 +863,89 @@
document.getElementById('add-handle-form').style.display = 'none';
document.getElementById('new-handle-name').value = '';
document.getElementById('new-handle-notes').value = '';
document.getElementById('new-handle-price').value = '';
document.getElementById('new-handle-status').value = 'reserved';
syncAddFormPriceVisibility();
}
async function addHandle() {
const handle = document.getElementById('new-handle-name').value.trim();
const status = document.getElementById('new-handle-status').value;
const script_pubkey = document.getElementById('new-handle-notes').value.trim();
const priceRaw = document.getElementById('new-handle-price').value.trim();
if (!handle) { showNotification('Handle is required.', 'error'); return; }
const body = { handle_name: handle, status, script_pubkey: script_pubkey || undefined };
if (status === 'reserved' && priceRaw !== '') {
body.price = parseInt(priceRaw, 10);
if (!Number.isFinite(body.price) || body.price < 0) {
showNotification('Price must be a non-negative integer (sats).', 'error');
return;
}
}
try {
const res = await fetch('/api/tenant/handles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle_name: handle, status, script_pubkey: script_pubkey || undefined })
body: JSON.stringify(body)
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
allHandles.unshift(data.handle);
hideAddHandleForm();
applyFilters();
showNotification(`Handle "${handle}" added with status "${status}".`, 'success');
const priceNote = status === 'reserved' && !priceRaw
? ' (no price — not purchasable until a price is set)'
: (priceRaw ? ` at ${priceRaw} sats` : '');
showNotification(`Handle "${handle}" added with status "${status}"${priceNote}.`, 'success');
} catch (err) {
showNotification('Error adding handle: ' + err.message, 'error');
}
}
async function savePrice(id) {
const input = document.getElementById(`price-input-${id}`);
if (!input) return;
const raw = input.value.trim();
if (!raw) {
showNotification('Enter a price in sats, or use Clear to remove pricing.', 'error');
return;
}
const price = parseInt(raw, 10);
if (!Number.isFinite(price) || price < 0) {
showNotification('Price must be a non-negative integer (sats).', 'error');
return;
}
await updatePrice(id, price);
}
async function clearPrice(id) {
if (!confirm('Clear price? This handle will be unavailable for purchase until a price is set.')) return;
await updatePrice(id, null);
}
async function updatePrice(id, price) {
try {
const res = await fetch(`/api/tenant/handles/${id}/price`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price })
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
const handle = allHandles.find(h => h.id === id);
if (handle) handle.price = data.handle ? data.handle.price : price;
applyFilters();
showNotification(
price == null
? 'Price cleared — handle is unavailable for purchase.'
: `Price set to ${price} sats (overrides tenant pricing strategy).`,
'success'
);
} catch (err) {
showNotification('Error updating price: ' + err.message, 'error');
}
}
// ── CSV Upload ────────────────────────────────────────────────────────
function setupFileInput() {
+53 -6
View File
@@ -117,6 +117,21 @@
background: #c0392b;
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #1e8449;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
@@ -523,12 +538,21 @@
<td>${escHtml(String(confirmInfo))}</td>
<td>${dateCell}</td>
<td>
<button class="btn btn-danger"
style="padding: 5px 12px; font-size: 0.82em;"
onclick="removePayment(${p.id}, '${escAttr(txShort)}')"
title="${isWatching ? 'Stop watching and remove from list' : 'Remove from list'}">
Remove
</button>
<div class="action-buttons">
<button class="btn btn-success"
style="padding: 5px 12px; font-size: 0.82em;"
onclick="confirmPayment(${p.id}, '${escAttr(txShort)}')"
${isWatching ? '' : 'disabled'}
title="${isWatching ? 'Manually confirm this transaction (same as Spaced callback)' : 'Already confirmed or not watching'}">
Confirm
</button>
<button class="btn btn-danger"
style="padding: 5px 12px; font-size: 0.82em;"
onclick="removePayment(${p.id}, '${escAttr(txShort)}')"
title="${isWatching ? 'Stop watching and remove from list' : 'Remove from list'}">
Remove
</button>
</div>
</td>
</tr>`;
}).join('');
@@ -599,6 +623,29 @@
}
}
async function confirmPayment(id, txShort) {
if (!confirm(`Manually confirm ${txShort ? 'transaction ' + txShort : 'this payment'} as on-chain confirmed?`)) return;
try {
const url = spaceParam
? `/api/tenant/payments/${id}/confirm?space=${encodeURIComponent(spaceParam)}`
: `/api/tenant/payments/${id}/confirm`;
const res = await fetch(url, { method: 'POST', credentials: 'same-origin' });
let data;
try {
data = await res.json();
} catch (_) {
throw new Error(res.status === 401 ? 'Authentication required' : `HTTP ${res.status}`);
}
if (!res.ok || !data.success) {
throw new Error(data.message || `HTTP ${res.status}`);
}
showNotification(data.message || 'Payment confirmed.', 'success');
await loadPayments();
} catch (err) {
showNotification('Error: ' + err.message, 'error');
}
}
async function removePayment(id, txShort) {
if (!confirm(`Remove ${txShort ? 'transaction ' + txShort : 'this payment'} from the monitor list?`)) return;
try {
+38
View File
@@ -110,6 +110,22 @@
background: var(--button-hover);
}
.btn-danger {
background: #e74c3c;
color: white;
padding: 5px 12px;
font-size: 0.82em;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-danger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.back-link {
display: inline-block;
color: rgba(255,255,255,0.85);
@@ -358,6 +374,10 @@
? `<ul class="purchase-list">${quote.purchases.map(renderPurchaseItem).join('')}</ul>`
: '<span class="purchase-meta">—</span>';
const deleteBtn = quote.purchased
? `<button class="btn btn-danger" disabled title="Cannot delete a quote with linked purchases">Delete</button>`
: `<button class="btn btn-danger" onclick="deleteQuote(${quote.id}, '${escAttr(quote.handle)}')">Delete</button>`;
return `
<tr>
<td>${quote.id}</td>
@@ -370,6 +390,7 @@
<td>${paidCell}</td>
<td>${stagedCell}</td>
<td>${subsStatusCell}</td>
<td>${deleteBtn}</td>
</tr>
`;
}).join('');
@@ -389,6 +410,7 @@
<th>Paid</th>
<th>SUBS staged</th>
<th>SUBS status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
@@ -430,6 +452,18 @@
return d.toLocaleString();
}
async function deleteQuote(id, handle) {
if (!confirm(`Delete quote #${id} for "${handle}"? This cannot be undone.`)) return;
try {
const res = await fetch(`/api/tenant/quotes/${id}`, { method: 'DELETE' });
const data = await res.json();
if (!data.success) throw new Error(data.message || `HTTP ${res.status}`);
await loadQuotes();
} catch (err) {
alert('Error deleting quote: ' + err.message);
}
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
@@ -437,6 +471,10 @@
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escAttr(str) {
return String(str).replace(/'/g, "\\'");
}
</script>
</body>
</html>
+605 -70
View File
@@ -37,6 +37,9 @@ bitcoin.initEccLib(ecc);
const app = express();
const HOST = process.env.PLATFORM_HOST || '127.0.0.1';
const PORT = process.env.PLATFORM_PORT || 3000;
/** Hostname Spaced uses to reach payment callbacks (must be reachable from the Spaced container). */
const PLATFORM_CALLBACK_HOST =
(process.env.PLATFORM_CALLBACK_HOST && String(process.env.PLATFORM_CALLBACK_HOST).trim()) || HOST;
const PUBLIC_PLATFORM_HOST = process.env.PUBLIC_PLATFORM_HOST;
const PLATFORM_MODE = process.env.PLATFORM_MODE || 'prod';
@@ -93,9 +96,15 @@ function getSpacesApiCertProxyPathPrefix() {
}
const SPACES_API_CERT_PROXY_PREFIX = getSpacesApiCertProxyPathPrefix();
// Callback base URL for Spaced TX callbacks — derived from PLATFORM_HOST and PLATFORM_PORT
// Callback base URL for Spaced TX callbacks — PLATFORM_CALLBACK_HOST + PLATFORM_PORT
const PLATFORM_PAYMENT_CALLBACK_BASE_URL = `http://${PLATFORM_CALLBACK_HOST}:${PORT}`;
// Legacy/general callback base (cert/prove webhooks); still uses bind host unless overridden by *_PUBLIC_URL
const PLATFORM_CALLBACK_BASE_URL = `http://${HOST}:${PORT}`;
function getPaymentCallbackUrl(tenantName) {
return `${PLATFORM_PAYMENT_CALLBACK_BASE_URL}/api/payments/callback?tenant=${encodeURIComponent(tenantName)}`;
}
// Commitment Configuration
const COMMITMENT_VBYTES = 256;
@@ -121,6 +130,12 @@ const FALLBACK_48_BLOCK_FEERATE_SAT_VB = 1;
// Test mode: use fallback fee rates instead of RPC calls
const USE_TEST_FEE_RATES = process.env.USE_TEST_FEE_RATES === 'true' || process.env.USE_TEST_FEE_RATES === '1';
/** Drop tenant quotes older than this many days before inserting new quotes (default 14). */
const KEEP_QUOTES_FOR_DAYS = (() => {
const parsed = parseInt(process.env.KEEP_QUOTES_FOR_DAYS || '14', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 14;
})();
/** When TRUE, tenant SUBS connections use config subs_alt_uri instead of subs_uri. */
const USE_SUBS_ALT_URI = String(process.env.USE_SUBS_ALT_URI || 'FALSE').toUpperCase() === 'TRUE';
@@ -180,6 +195,12 @@ function logEnvironmentVariables() {
defaultValue: '3000',
description: 'HTTP server port'
},
{
name: 'PLATFORM_CALLBACK_HOST',
value: process.env.PLATFORM_CALLBACK_HOST,
defaultValue: '(same as PLATFORM_HOST)',
description: 'Hostname Spaced uses for payment TX callbacks (reachable from Spaced container)'
},
{
name: 'PLATFORM_MODE',
value: process.env.PLATFORM_MODE,
@@ -3360,10 +3381,16 @@ app.get('/tenant-payments', requireAdminOrTenant, setupTenantDatabase, (req, res
});
// Serve tenant quotes page
app.get('/tenant-quotes', requireAdminOrTenant, setupTenantDatabase, (req, res) => {
app.get('/tenant-quotes', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
if (req.adminUser) {
const requestedSpace = req.query.space;
if (!requestedSpace) {
if (req.query.handle) {
return res.status(400).json({
success: false,
message: 'space parameter required when handle is provided',
});
}
return res.status(400).send(`
<html>
<head><title>Error - SpaceOps</title></head>
@@ -3371,9 +3398,35 @@ app.get('/tenant-quotes', requireAdminOrTenant, setupTenantDatabase, (req, res)
</html>
`);
}
res.sendFile(path.join(__dirname, 'public', 'tenant-quotes.html'));
return;
}
const handleParam = req.query.handle;
if (handleParam && String(handleParam).trim()) {
try {
const spaceName = String(req.query.space || req.spaceName || req.tenantName || '').trim();
if (!spaceName) {
return res.status(400).json({
success: false,
message: 'space is required when handle is provided',
});
}
const tenantDb = await getTenantDatabase(req.tenantName);
const result = await getTenantHandleQuoteState(tenantDb, spaceName, handleParam);
if (!result.found) {
return res.status(404).json({
success: false,
message: result.message || 'Handle not found',
space: spaceName,
handle: result.handle,
});
}
return res.json(result);
} catch (error) {
console.error('[quotes] Error loading handle state:', error);
return res.status(500).json({ success: false, message: error.message });
}
}
res.sendFile(path.join(__dirname, 'public', 'tenant-quotes.html'));
});
@@ -4092,10 +4145,12 @@ app.post('/spaces/:spaceName/:subspace', async (req, res) => {
});
}
if (quoteRow.state === 'taken') {
if (quoteRow.state === 'taken' || quoteRow.state === 'unavailable') {
return res.status(400).json({
success: false,
message: 'Handle is no longer available for purchase',
message: quoteRow.state === 'unavailable'
? 'Handle is reserved without a preset price and cannot be purchased'
: 'Handle is no longer available for purchase',
});
}
@@ -6139,7 +6194,7 @@ async function registerSpacedPaymentWatch(tenantName, tenantDb, txid, purchaseId
}
const clientId = getPaymentClientId(tenantName);
const callbackUrl = `${PLATFORM_CALLBACK_BASE_URL}/api/payments/callback?tenant=${encodeURIComponent(tenantName)}`;
const callbackUrl = getPaymentCallbackUrl(tenantName);
const allWatching = await new Promise((resolve, reject) => {
tenantDb.all(
`SELECT transaction_id FROM payments WHERE status = 'watching'`,
@@ -7481,6 +7536,61 @@ function parseSubsHandleLookupResponse(httpStatus, parsedBody, spaceName, subspa
};
}
function parseTenantHandlePrice(raw) {
if (raw === undefined || raw === null || String(raw).trim() === '') {
return null;
}
const parsed = parseInt(String(raw).trim(), 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return parsed;
}
function lookupTenantReservedHandle(tenantDb, subspace) {
const key = String(subspace || '').trim();
if (!key) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => {
tenantDb.get(
`SELECT * FROM handles WHERE LOWER(handle) = LOWER(?) AND status = 'reserved'`,
[key],
(err, row) => {
if (err) {
reject(err);
} else {
resolve(row || null);
}
}
);
});
}
function purgeExpiredTenantQuotes(tenantDb) {
const cutoffModifier = `-${KEEP_QUOTES_FOR_DAYS} days`;
return new Promise((resolve, reject) => {
tenantDb.run(
`DELETE FROM quotes
WHERE datetime(created_at) < datetime('now', ?)
AND id NOT IN (SELECT quote_id FROM purchases WHERE quote_id IS NOT NULL)`,
[cutoffModifier],
function (err) {
if (err) {
reject(err);
return;
}
if (this.changes > 0) {
console.log(
`[quotes] Purged ${this.changes} quote(s) older than ${KEEP_QUOTES_FOR_DAYS} days (no linked purchases)`
);
}
resolve(this.changes);
}
);
});
}
function getTenantConfigValueFromDb(tenantDb, key) {
return new Promise((resolve, reject) => {
tenantDb.get('SELECT value FROM config WHERE key = ?', [key], (err, row) => {
@@ -7728,6 +7838,19 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
} else {
console.log(`[spaces-proxy] Handle ${responseData.handle} is available for sale; computing tenant subname price`);
const tenantReserved = await lookupTenantReservedHandle(tenantDb, subspace);
const reservedPrice = parseTenantHandlePrice(tenantReserved?.price);
if (tenantReserved && reservedPrice == null) {
responseData.state = 'unavailable';
responseData.id = 0;
delete responseData.price;
console.log(
`[spaces-proxy] Reserved handle ${subspace} has no preset price — state=unavailable (not purchasable)`
);
modifiedBody = JSON.stringify(responseData);
} else {
// Get RPC fee estimates first (used for both platform fees and SPTR fees)
let rpcEstimate1, rpcEstimate6, rpcEstimate48;
try {
@@ -7864,11 +7987,18 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
console.log(`[spaces-proxy] Skipping fee replacement due to config error`);
}
responseData.price = await computeSubnamePrice(tenantDb, {
subspace,
spaceName,
handle: responseData.handle,
});
responseData.price = reservedPrice != null
? reservedPrice
: await computeSubnamePrice(tenantDb, {
subspace,
spaceName,
handle: responseData.handle,
});
if (reservedPrice != null) {
console.log(
`[spaces-proxy] Using reserved handle price ${reservedPrice} sats for ${subspace} (overrides tenant pricing strategy)`
);
}
// Check for pending purchase for this handle
if (responseData.handle) {
@@ -7956,11 +8086,16 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
let quoteId = null;
if (responseData.handle && responseData.price !== undefined && responseData.state) {
// If state is "taken", don't insert and set id to 0
if (responseData.state === 'taken') {
if (responseData.state === 'taken' || responseData.state === 'unavailable') {
quoteId = 0;
console.log(`[spaces-proxy] Quote state is "taken", skipping insertion and setting id to 0`);
console.log(`[spaces-proxy] Quote state is "${responseData.state}", skipping insertion and setting id to 0`);
} else {
try {
try {
await purgeExpiredTenantQuotes(tenantDb);
} catch (purgeErr) {
console.error(`[spaces-proxy] Error purging expired quotes:`, purgeErr.message);
}
quoteId = await new Promise((resolve, reject) => {
const insertQuery = `
INSERT INTO quotes (handle, price, state, "1_block_fee", "6_block_fee", "48_block_fee", sptr_price, "1_block_sptr_fee", "6_block_sptr_fee", "48_block_sptr_fee")
@@ -8004,7 +8139,7 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
// Apply coupon discount to price if a valid coupon query param is provided
const couponParam = req.query.coupon;
if (couponParam && responseData.price !== undefined && responseData.state !== 'taken') {
if (couponParam && responseData.price !== undefined && responseData.state !== 'taken' && responseData.state !== 'unavailable') {
const normalizedCoupon = couponParam.trim().toUpperCase();
try {
const coupon = await new Promise((resolve, reject) => {
@@ -8045,6 +8180,7 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
}
}
}
}
} catch (parseError) {
console.log(`[spaces-proxy] Response is not valid JSON, skipping fee replacement: ${parseError.message}`);
@@ -8952,18 +9088,24 @@ app.post('/api/tenant/handles/refresh', requireAdminOrTenant, setupTenantDatabas
app.post('/api/tenant/handles', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const { handle_name, status, script_pubkey } = req.body;
const { handle_name, status, script_pubkey, price } = req.body;
console.log(`[handle] POST /api/tenant/handles tenant=${req.tenantName} handle=${handle_name}`);
if (!handle_name || !handle_name.trim()) {
return res.status(400).json({ success: false, message: 'handle_name is required' });
}
const validStatuses = ['reserved', 'available', 'staged', 'committed', 'parked', 'published', 'unpublished'];
const resolvedStatus = status && validStatuses.includes(status) ? status : 'reserved';
const resolvedPrice = price === undefined || price === null || String(price).trim() === ''
? null
: parseTenantHandlePrice(price);
if (price !== undefined && price !== null && String(price).trim() !== '' && resolvedPrice == null) {
return res.status(400).json({ success: false, message: 'price must be a non-negative integer (sats)' });
}
const tenantDb = await getTenantDatabase(req.tenantName);
const newId = await new Promise((resolve, reject) => {
tenantDb.run(
'INSERT INTO handles (handle, status, script_pubkey) VALUES (?, ?, ?)',
[handle_name.trim(), resolvedStatus, script_pubkey || null],
'INSERT INTO handles (handle, status, script_pubkey, price) VALUES (?, ?, ?, ?)',
[handle_name.trim(), resolvedStatus, script_pubkey || null, resolvedPrice],
function (err) {
if (err) { reject(err); return; }
resolve(this.lastID);
@@ -9001,11 +9143,26 @@ app.post('/api/tenant/handles/upload-csv', requireAdminOrTenant, setupTenantData
const tenantDb = await getTenantDatabase(req.tenantName);
let inserted = 0;
let skipped = 0;
for (const name of lines) {
for (const line of lines) {
const parts = line.split(',').map((p) => p.trim());
const name = parts[0];
if (!name) {
continue;
}
const priceRaw = parts.length > 1 ? parts[1] : null;
const resolvedPrice = priceRaw == null || priceRaw === ''
? null
: parseTenantHandlePrice(priceRaw);
if (priceRaw != null && priceRaw !== '' && resolvedPrice == null) {
return res.status(400).json({
success: false,
message: `Invalid price for handle "${name}": must be a non-negative integer (sats)`,
});
}
const changes = await new Promise((resolve, reject) => {
tenantDb.run(
'INSERT OR IGNORE INTO handles (handle, status) VALUES (?, ?)',
[name, 'reserved'],
'INSERT OR IGNORE INTO handles (handle, status, price) VALUES (?, ?, ?)',
[name, 'reserved', resolvedPrice],
function (err) {
if (err) { reject(err); return; }
resolve(this.changes);
@@ -9060,6 +9217,51 @@ app.put('/api/tenant/handles/:handleId/status', requireAdminOrTenant, setupTenan
}
});
app.put('/api/tenant/handles/:handleId/price', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const handleId = parseInt(req.params.handleId, 10);
const { price } = req.body;
console.log(`[handle] PUT /api/tenant/handles/${handleId}/price tenant=${req.tenantName} price=${price}`);
if (isNaN(handleId)) {
return res.status(400).json({ success: false, message: 'Invalid handle ID' });
}
const resolvedPrice = price === undefined || price === null || String(price).trim() === ''
? null
: parseTenantHandlePrice(price);
if (price !== undefined && price !== null && String(price).trim() !== '' && resolvedPrice == null) {
return res.status(400).json({ success: false, message: 'price must be a non-negative integer (sats), or null to clear' });
}
const tenantDb = await getTenantDatabase(req.tenantName);
const existing = await new Promise((resolve, reject) => {
tenantDb.get('SELECT * FROM handles WHERE id = ?', [handleId], (err, row) => {
if (err) { reject(err); return; }
resolve(row);
});
});
if (!existing) {
return res.status(404).json({ success: false, message: 'Handle not found' });
}
await new Promise((resolve, reject) => {
tenantDb.run(
'UPDATE handles SET price = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[resolvedPrice, handleId],
(err) => { if (err) { reject(err); return; } resolve(); }
);
});
const updated = await new Promise((resolve, reject) => {
tenantDb.get('SELECT * FROM handles WHERE id = ?', [handleId], (err, row) => {
if (err) { reject(err); return; }
resolve(row);
});
});
console.log(`[handle] PUT /api/tenant/handles/${handleId}/price response: updated`);
res.json({ success: true, message: 'Handle price updated successfully', handle: updated });
} catch (error) {
console.error('[handle] Error updating handle price:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.delete('/api/tenant/handles/:handleId', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const handleId = parseInt(req.params.handleId, 10);
@@ -9085,6 +9287,190 @@ app.delete('/api/tenant/handles/:handleId', requireAdminOrTenant, setupTenantDat
}
});
// ─── Tenant quotes helpers ───────────────────────────────────────────────────
function normalizeTenantHandleQuery(handleParam, spaceName) {
const raw = String(handleParam || '').trim();
const space = String(spaceName || '').trim().replace(/^@/, '');
if (!raw) {
return null;
}
if (raw.includes('@')) {
return raw;
}
if (!space) {
return raw;
}
return `${raw}@${space}`;
}
function enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap) {
const purchaseRows = (quotePurchases || []).map((purchase) => {
const subsInfo = subsStatusMap.get(String(purchase.handle || '').trim().toLowerCase());
const subsHandleStatus = subsInfo?.status || null;
return {
id: purchase.id,
handle: purchase.handle,
purchase_type: purchase.purchase_type || 'subname',
status: purchase.status,
unified_status: purchase.unified_status,
job_id: purchase.job_id,
created_at: purchase.created_at,
payment_confirmed: isPurchasePaymentConfirmed(purchase),
subs_handle_status: subsHandleStatus,
subs_staged: isSubsHandleStagedStatus(subsHandleStatus),
};
});
const subnamePurchase =
purchaseRows.find((row) => (row.purchase_type || 'subname') === 'subname') || null;
const handleKey = String(quote?.handle || subnamePurchase?.handle || '').trim().toLowerCase();
const subsInfo = handleKey ? subsStatusMap.get(handleKey) : null;
const subsHandleStatus =
subnamePurchase?.subs_handle_status || subsInfo?.status || null;
return {
id: quote?.id ?? null,
handle: quote?.handle || subnamePurchase?.handle || null,
price: quote?.price ?? null,
state: quote?.state ?? null,
block_fees: quote
? {
'1': quote['1_block_fee'],
'6': quote['6_block_fee'],
'48': quote['48_block_fee'],
}
: null,
created_at: quote?.created_at ?? null,
purchased: purchaseRows.length > 0,
purchase_count: purchaseRows.length,
purchases: purchaseRows,
payment_confirmed: subnamePurchase
? subnamePurchase.payment_confirmed
: purchaseRows.some((row) => row.payment_confirmed),
subs_handle_status: subsHandleStatus,
subs_staged: subnamePurchase ? subnamePurchase.subs_staged : isSubsHandleStagedStatus(subsHandleStatus),
subs_publish_status: subsInfo?.publish_status ?? null,
subs_parked: subsInfo?.parked ?? null,
};
}
async function loadSubsStatusMapForSpace(tenantDb, spaceName) {
let subsConfigured = false;
let subsError = null;
const subsStatusMap = new Map();
try {
const subsUri = await getTenantSubsUriFromDb(tenantDb);
if (subsUri) {
subsConfigured = true;
const subsHandles = await fetchAllSubsHandlesForSpace(subsUri, spaceName, tenantDb);
return {
subsConfigured,
subsError,
subsStatusMap: buildSubsHandleStatusMap(subsHandles, spaceName),
};
}
} catch (err) {
subsError = err.message;
}
return { subsConfigured, subsError, subsStatusMap };
}
async function loadSubsStatusForHandle(tenantDb, spaceName, handle) {
const subsUri = await getTenantSubsUriFromDb(tenantDb);
if (!subsUri) {
return { subsConfigured: false, subsError: null, subsStatusMap: new Map() };
}
const parts = parseHandleToSpaceAndSubspace(handle);
if (!parts) {
return { subsConfigured: true, subsError: null, subsStatusMap: new Map() };
}
try {
const subsResult = await fetchSubsHandleRecord(tenantDb, parts.spaceName, parts.subspace);
const map = new Map();
if (subsResult.ok) {
const status = String(subsResult.handle.status || '').trim().toLowerCase();
map.set(String(handle).trim().toLowerCase(), {
status,
publish_status: subsResult.handle.publish_status ?? null,
parked: subsResult.handle.parked ?? null,
script_pubkey: subsResult.handle.script_pubkey || null,
});
}
return { subsConfigured: true, subsError: null, subsStatusMap: map };
} catch (err) {
return { subsConfigured: true, subsError: err.message, subsStatusMap: new Map() };
}
}
async function getTenantHandleQuoteState(tenantDb, spaceName, handleParam) {
const handle = normalizeTenantHandleQuery(handleParam, spaceName);
if (!handle) {
return { found: false, message: 'handle is required' };
}
const quote = await new Promise((resolve, reject) => {
tenantDb.get(
`SELECT * FROM quotes WHERE LOWER(handle) = LOWER(?) ORDER BY id DESC LIMIT 1`,
[handle],
(err, row) => {
if (err) {
reject(err);
} else {
resolve(row || null);
}
}
);
});
const purchases = await new Promise((resolve, reject) => {
tenantDb.all(
`SELECT id, quote_id, handle, purchase_type, status, unified_status, job_id, created_at
FROM purchases
WHERE LOWER(handle) = LOWER(?)
ORDER BY id ASC`,
[handle],
(err, rows) => {
if (err) {
reject(err);
} else {
resolve(rows || []);
}
}
);
});
const { subsConfigured, subsError, subsStatusMap } = await loadSubsStatusForHandle(
tenantDb,
spaceName,
handle
);
const hasSubsEntry = subsStatusMap.has(String(handle).trim().toLowerCase());
if (!quote && purchases.length === 0 && !hasSubsEntry) {
return {
found: false,
message: `No quote, purchase, or SUBS record found for ${handle}`,
handle,
};
}
const state = enrichQuoteWithPurchasesAndSubs(quote, purchases, subsStatusMap);
return {
found: true,
success: true,
space: spaceName,
handle,
subs_configured: subsConfigured,
subs_error: subsError,
state,
};
}
// ─── Payments Monitor API ────────────────────────────────────────────────────
app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
@@ -9092,6 +9478,20 @@ app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (
const tenantDb = await getTenantDatabase(req.tenantName);
const spaceName = String(req.spaceName || req.query.space || req.tenantName || '').trim();
const handleParam = req.query.handle;
if (handleParam && String(handleParam).trim()) {
const result = await getTenantHandleQuoteState(tenantDb, spaceName, handleParam);
if (!result.found) {
return res.status(404).json({
success: false,
message: result.message || 'Handle not found',
space: spaceName,
handle: result.handle,
});
}
return res.json(result);
}
const quotes = await new Promise((resolve, reject) => {
tenantDb.all('SELECT * FROM quotes ORDER BY id DESC', (err, rows) => {
if (err) {
@@ -9133,57 +9533,14 @@ app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (
let subsStatusMap = new Map();
let subsConfigured = false;
let subsError = null;
try {
const subsUri = await getTenantSubsUriFromDb(tenantDb);
if (subsUri) {
subsConfigured = true;
const subsHandles = await fetchAllSubsHandlesForSpace(subsUri, spaceName, tenantDb);
subsStatusMap = buildSubsHandleStatusMap(subsHandles, spaceName);
}
} catch (err) {
subsError = err.message;
}
({ subsConfigured, subsError, subsStatusMap } = await loadSubsStatusMapForSpace(
tenantDb,
spaceName
));
const enrichedQuotes = quotes.map((quote) => {
const quotePurchases = purchasesByQuoteId.get(quote.id) || [];
const purchaseRows = quotePurchases.map((purchase) => {
const subsInfo = subsStatusMap.get(String(purchase.handle || '').trim().toLowerCase());
const subsHandleStatus = subsInfo?.status || null;
return {
id: purchase.id,
handle: purchase.handle,
purchase_type: purchase.purchase_type || 'subname',
status: purchase.status,
unified_status: purchase.unified_status,
job_id: purchase.job_id,
created_at: purchase.created_at,
payment_confirmed: isPurchasePaymentConfirmed(purchase),
subs_handle_status: subsHandleStatus,
subs_staged: isSubsHandleStagedStatus(subsHandleStatus),
};
});
const subnamePurchase =
purchaseRows.find((row) => (row.purchase_type || 'subname') === 'subname') || null;
return {
id: quote.id,
handle: quote.handle,
price: quote.price,
state: quote.state,
block_fees: {
'1': quote['1_block_fee'],
'6': quote['6_block_fee'],
'48': quote['48_block_fee'],
},
created_at: quote.created_at,
purchased: purchaseRows.length > 0,
purchase_count: purchaseRows.length,
purchases: purchaseRows,
payment_confirmed: subnamePurchase ? subnamePurchase.payment_confirmed : purchaseRows.some((row) => row.payment_confirmed),
subs_handle_status: subnamePurchase?.subs_handle_status || null,
subs_staged: subnamePurchase ? subnamePurchase.subs_staged : false,
};
return enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap);
});
console.log(`[quotes] GET /api/tenant/quotes tenant=${req.tenantName} quotes=${enrichedQuotes.length}`);
@@ -9200,6 +9557,60 @@ app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (
}
});
app.delete('/api/tenant/quotes/:quoteId', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const quoteId = parseInt(req.params.quoteId, 10);
console.log(`[quotes] DELETE /api/tenant/quotes/${req.params.quoteId} tenant=${req.tenantName}`);
if (isNaN(quoteId)) {
return res.status(400).json({ success: false, message: 'Invalid quote ID' });
}
const tenantDb = await getTenantDatabase(req.tenantName);
const quote = await new Promise((resolve, reject) => {
tenantDb.get('SELECT id, handle FROM quotes WHERE id = ?', [quoteId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
if (!quote) {
return res.status(404).json({ success: false, message: 'Quote not found' });
}
const linkedPurchases = await new Promise((resolve, reject) => {
tenantDb.get(
'SELECT COUNT(*) AS count FROM purchases WHERE quote_id = ?',
[quoteId],
(err, row) => {
if (err) reject(err);
else resolve(row ? row.count : 0);
}
);
});
if (linkedPurchases > 0) {
return res.status(409).json({
success: false,
message: `Cannot delete quote #${quoteId}: ${linkedPurchases} linked purchase(s) exist`,
});
}
const changes = await new Promise((resolve, reject) => {
tenantDb.run('DELETE FROM quotes WHERE id = ?', [quoteId], function (err) {
if (err) reject(err);
else resolve(this.changes);
});
});
if (changes === 0) {
return res.status(404).json({ success: false, message: 'Quote not found' });
}
console.log(`[quotes] DELETE /api/tenant/quotes/${quoteId} response: deleted handle=${quote.handle}`);
res.json({ success: true, message: `Quote #${quoteId} deleted successfully` });
} catch (error) {
console.error('[quotes] Error deleting quote:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.get('/api/tenant/payments', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const tenantDb = await getTenantDatabase(req.tenantName);
@@ -9406,6 +9817,86 @@ app.delete('/api/tenant/payments/:id', requireAdminOrTenant, setupTenantDatabase
}
});
app.post('/api/tenant/payments/:id/confirm', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const paymentId = parseInt(req.params.id, 10);
if (isNaN(paymentId)) {
return res.status(400).json({ success: false, message: 'Invalid payment ID' });
}
const tenantDb = await getTenantDatabase(req.tenantName);
const paymentRow = await new Promise((resolve, reject) => {
tenantDb.get(
'SELECT id, transaction_id, status, purchase_id FROM payments WHERE id = ?',
[paymentId],
(err, row) => {
if (err) {
reject(err);
} else {
resolve(row || null);
}
}
);
});
if (!paymentRow) {
return res.status(404).json({ success: false, message: 'Payment not found' });
}
if (paymentRow.status === 'confirmed') {
return res.json({
success: true,
message: 'Payment is already confirmed',
already_confirmed: true,
payment_id: paymentRow.id,
});
}
if (paymentRow.status !== 'watching') {
return res.status(409).json({
success: false,
message: `Payment cannot be confirmed while status is "${paymentRow.status}"`,
});
}
const txidLower = String(paymentRow.transaction_id || '').trim().toLowerCase();
if (isSimulatedPaymentTxid(txidLower)) {
cancelSimulatedPaymentConfirmation(req.tenantName, txidLower);
}
const txMeta = await tryResolveTxMetaFromSpaced(txidLower);
const result = await confirmPaymentTxOnChain(req.tenantName, tenantDb, txidLower, {
block_height: txMeta.block_height ?? null,
block_hash: txMeta.block_hash ?? null,
confirmations: txMeta.confirmations ?? null,
allowSimulatedDefaults: true,
});
if (result.changes === 0) {
return res.status(409).json({
success: false,
message: 'Payment could not be confirmed (still watching)',
});
}
console.log(
`[payments] manual confirm tenant=${req.tenantName} payment_id=${paymentRow.id} txid=${txidLower} purchase_confirmed=${result.purchaseConfirmed}`
);
res.json({
success: true,
message: 'Payment confirmed and watch removed',
payment_id: paymentRow.id,
transaction_id: txidLower,
purchase_confirmed: result.purchaseConfirmed,
purchase_id: paymentRow.purchase_id,
});
} catch (error) {
console.error('[payments] Error confirming payment:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Callback endpoint — called by Spaced daemon or Spaces Wallet, no session auth required
app.post('/api/payments/callback', async (req, res) => {
try {
@@ -10998,6 +11489,50 @@ async function getTxCallback(clientId) {
return spacedTxCallbackRpc('gettxcallback', { client_id: clientId });
}
function parseSpacedTxMetaResult(meta, tipHeight = null) {
if (!meta || typeof meta !== 'object') {
return {};
}
const blockHeight = parseOptionalPaymentInt(
meta.block_height ??
meta.blockheight ??
meta.height ??
meta.block?.height ??
meta.block?.block_height
);
let confirmations = parseOptionalPaymentInt(meta.confirmations ?? meta.confirmation_count);
const blockHashRaw = meta.block_hash ?? meta.blockhash ?? meta.block?.hash ?? null;
const blockHash =
blockHashRaw != null && String(blockHashRaw).trim() !== '' ? String(blockHashRaw).trim() : null;
if (confirmations == null && blockHeight != null && tipHeight != null && tipHeight >= blockHeight) {
confirmations = tipHeight - blockHeight + 1;
}
return {
block_height: blockHeight,
block_hash: blockHash,
confirmations,
};
}
async function tryResolveTxMetaFromSpaced(txidLower) {
try {
const meta = await spacedTxCallbackRpc('gettxmeta', { txid: txidLower });
let tipHeight = null;
try {
const info = await checkSpacedServerInfo();
if (info.success) {
tipHeight = parseOptionalPaymentInt(info.height);
}
} catch (_err) {
/* ignore tip lookup errors */
}
return parseSpacedTxMetaResult(meta, tipHeight);
} catch (err) {
console.warn(`[payments] gettxmeta failed for ${txidLower}: ${err.message}`);
return {};
}
}
/**
* On startup: re-register all tenant TX callback clients and restore their watched txids.
* Called inside app.listen after existing startup checks.
@@ -11042,7 +11577,7 @@ async function reestablishPaymentMonitoring() {
}
const txids = rows.map((r) => r.transaction_id);
const clientId = getPaymentClientId(tenantName);
const callbackUrl = `${PLATFORM_CALLBACK_BASE_URL}/api/payments/callback?tenant=${encodeURIComponent(tenantName)}`;
const callbackUrl = getPaymentCallbackUrl(tenantName);
try {
await registerTxCallback(clientId, callbackUrl);
await updateTxWatches(clientId, txids);
+1
View File
@@ -9,6 +9,7 @@ export PLATFORM_MODE=prod
export PUBLIC_PLATFORM_HOST=spacesops.com
export PLATFORM_HOST=0.0.0.0
export PLATFORM_PORT=7264
export PLATFORM_CALLBACK_HOST=spacesops.startos
export SPACED_RPC_HOST=natural-dean.local
export SPACED_RPC_PORT=52611