initial pricing logic

This commit is contained in:
2026-06-14 11:19:31 -04:00
parent c7d13ff4cf
commit 4445d0a458
3 changed files with 995 additions and 157 deletions
+211
View File
@@ -0,0 +1,211 @@
'use strict';
/** Default flat subname price (sats) — single source of truth for base_price mode and tier fallback. */
const DEFAULT_SUBNAME_BASE_PRICE = 50000;
const SUBNAME_PRICING_MODE_BASE = 'base_price';
const SUBNAME_PRICING_MODE_LENGTH_TIER = 'length_tier';
const VALID_SUBNAME_PRICING_MODES = [
SUBNAME_PRICING_MODE_BASE,
SUBNAME_PRICING_MODE_LENGTH_TIER,
];
const DEFAULT_SUBNAME_PRICING_MODE = SUBNAME_PRICING_MODE_LENGTH_TIER;
const DEFAULT_SUBNAME_LENGTH_TIERS = [
{ max_length: 1, price_sats: 2000000 },
{ max_length: 2, price_sats: 1000000 },
{ max_length: 3, price_sats: 150000 },
{ max_length: 6, price_sats: 100000 },
{ max_length: 12, price_sats: 75000 },
{ max_length: null, price_sats: DEFAULT_SUBNAME_BASE_PRICE },
];
const SUBNAME_PRICING_CONFIG_KEYS = [
'subname_pricing_mode',
'subname_base_price',
'subname_pricing_length_tiers',
];
const SUBNAME_PRICING_MODE_OPTIONS = [
{
id: SUBNAME_PRICING_MODE_BASE,
label: 'Fixed base price',
description: 'Same price in sats for every available subname.',
},
{
id: SUBNAME_PRICING_MODE_LENGTH_TIER,
label: 'Length tiers',
description: 'Price depends on the number of characters in the subname.',
},
];
const TENANT_SUBNAME_PRICING_DEFAULTS = {
subname_pricing_mode: DEFAULT_SUBNAME_PRICING_MODE,
subname_base_price: String(DEFAULT_SUBNAME_BASE_PRICE),
subname_pricing_length_tiers: JSON.stringify(DEFAULT_SUBNAME_LENGTH_TIERS),
};
function parseNonNegativeInt(value, defaultValue) {
const parsed = parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return defaultValue;
}
return parsed;
}
function normalizePricingMode(raw) {
const mode = String(raw || DEFAULT_SUBNAME_PRICING_MODE).trim();
return VALID_SUBNAME_PRICING_MODES.includes(mode) ? mode : DEFAULT_SUBNAME_PRICING_MODE;
}
function parseLengthTiers(raw) {
let parsed;
if (Array.isArray(raw)) {
parsed = raw;
} else if (typeof raw === 'string' && raw.trim()) {
parsed = JSON.parse(raw);
} else {
return DEFAULT_SUBNAME_LENGTH_TIERS.map((tier) => ({ ...tier }));
}
if (!Array.isArray(parsed) || parsed.length === 0) {
throw new Error('length_tiers must be a non-empty array');
}
const tiers = parsed.map((tier, index) => {
const maxLengthRaw = tier.max_length;
const maxLength =
maxLengthRaw === null || maxLengthRaw === undefined || maxLengthRaw === ''
? null
: parseNonNegativeInt(maxLengthRaw, -1);
if (maxLength !== null && maxLength < 1) {
throw new Error(`length_tiers[${index}].max_length must be a positive integer or null`);
}
const priceSats = parseNonNegativeInt(tier.price_sats, -1);
if (priceSats < 0) {
throw new Error(`length_tiers[${index}].price_sats must be a non-negative integer`);
}
return { max_length: maxLength, price_sats: priceSats };
});
tiers.sort((a, b) => {
if (a.max_length === null) {
return 1;
}
if (b.max_length === null) {
return -1;
}
return a.max_length - b.max_length;
});
if (tiers[tiers.length - 1].max_length !== null) {
throw new Error('The last length tier must use null max_length as the default catch-all');
}
return tiers;
}
function computeLengthTierPrice(subspace, tiers) {
const length = String(subspace || '').length;
for (const tier of tiers) {
if (tier.max_length === null || length <= tier.max_length) {
return tier.price_sats;
}
}
return tiers[tiers.length - 1].price_sats;
}
function buildSubnamePricingSettings(configMap) {
const mode = normalizePricingMode(configMap.subname_pricing_mode);
let lengthTiers;
try {
lengthTiers = parseLengthTiers(configMap.subname_pricing_length_tiers);
} catch (_err) {
lengthTiers = DEFAULT_SUBNAME_LENGTH_TIERS.map((tier) => ({ ...tier }));
}
return {
mode,
base_price: parseNonNegativeInt(configMap.subname_base_price, DEFAULT_SUBNAME_BASE_PRICE),
length_tiers: lengthTiers,
};
}
function validateSubnamePricingPayload(body) {
if (!body || typeof body !== 'object') {
throw new Error('Request body is required');
}
const mode = normalizePricingMode(body.mode);
const basePrice = parseNonNegativeInt(body.base_price, -1);
if (basePrice < 0) {
throw new Error('base_price must be a non-negative integer (sats)');
}
const lengthTiers = parseLengthTiers(body.length_tiers);
return {
mode,
base_price: basePrice,
length_tiers: lengthTiers,
};
}
function pricingSettingsToConfigEntries(settings) {
return {
subname_pricing_mode: settings.mode,
subname_base_price: String(settings.base_price),
subname_pricing_length_tiers: JSON.stringify(settings.length_tiers),
};
}
async function loadSubnamePricingSettings(tenantDb, getTenantConfigValueFromDb) {
const configMap = {};
for (const key of SUBNAME_PRICING_CONFIG_KEYS) {
configMap[key] = await getTenantConfigValueFromDb(tenantDb, key);
}
return buildSubnamePricingSettings(configMap);
}
function computeSubnamePriceFromSettings(settings, { subspace }) {
switch (settings.mode) {
case SUBNAME_PRICING_MODE_LENGTH_TIER:
return computeLengthTierPrice(subspace, settings.length_tiers);
case SUBNAME_PRICING_MODE_BASE:
default:
return settings.base_price;
}
}
async function computeSubnamePrice(tenantDb, context, helpers) {
const settings = await loadSubnamePricingSettings(tenantDb, helpers.getTenantConfigValueFromDb);
const price = computeSubnamePriceFromSettings(settings, {
subspace: context.subspace,
});
console.log(
`[pricing] mode=${settings.mode} subspace=${context.subspace} price=${price} sats`
);
return price;
}
module.exports = {
DEFAULT_SUBNAME_BASE_PRICE,
DEFAULT_SUBNAME_PRICING_MODE,
DEFAULT_SUBNAME_LENGTH_TIERS,
SUBNAME_PRICING_CONFIG_KEYS,
SUBNAME_PRICING_MODE_OPTIONS,
SUBNAME_PRICING_MODE_BASE,
SUBNAME_PRICING_MODE_LENGTH_TIER,
TENANT_SUBNAME_PRICING_DEFAULTS,
buildSubnamePricingSettings,
computeLengthTierPrice,
computeSubnamePriceFromSettings,
computeSubnamePrice,
loadSubnamePricingSettings,
normalizePricingMode,
parseLengthTiers,
pricingSettingsToConfigEntries,
validateSubnamePricingPayload,
};
+368 -6
View File
@@ -305,6 +305,126 @@
background: #5a6268;
transform: translateY(-1px);
}
.pricing-mode-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 20px;
}
.pricing-mode-option {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 16px;
border: 2px solid #e1e5e9;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.pricing-mode-option.selected {
border-color: var(--accent-color);
background: rgba(128, 114, 229, 0.06);
}
.pricing-mode-option input {
margin-top: 4px;
}
.pricing-mode-copy strong {
display: block;
color: var(--text-primary);
margin-bottom: 4px;
}
.pricing-mode-copy span {
color: var(--text-secondary);
font-size: 0.92em;
line-height: 1.4;
}
.pricing-panel {
display: none;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #ecf0f1;
}
.pricing-panel.active {
display: block;
}
.pricing-field {
margin-bottom: 16px;
}
.pricing-field label {
display: block;
font-weight: 500;
color: #34495e;
margin-bottom: 6px;
}
.pricing-field input {
width: 100%;
max-width: 280px;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1em;
}
.pricing-tier-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
}
.pricing-tier-table th,
.pricing-tier-table td {
padding: 10px;
border-bottom: 1px solid #ecf0f1;
text-align: left;
}
.pricing-tier-table input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
}
.pricing-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 20px;
}
.pricing-preview {
margin-top: 16px;
padding: 12px 14px;
background: #f8f9fa;
border-radius: 6px;
color: #34495e;
font-family: 'Courier New', monospace;
}
.secondary-btn {
background: #6c757d;
color: white;
border: none;
padding: 10px 18px;
border-radius: 6px;
cursor: pointer;
}
.secondary-btn:hover {
background: #5a6268;
}
</style>
</head>
<body>
@@ -357,6 +477,46 @@
<span class="config-value" id="relay3"></span>
</div>
</div>
<div class="config-section" id="subname-pricing-section">
<h2>Subname Pricing</h2>
<p style="color: var(--text-secondary); margin-top: 0;">
Choose one pricing method for available subnames. Only the selected option is used for quotes.
</p>
<div id="pricing-mode-list" class="pricing-mode-list"></div>
<div id="pricing-panel-base" class="pricing-panel">
<div class="pricing-field">
<label for="pricing-base-price">Base price (sats)</label>
<input type="number" id="pricing-base-price" min="0" step="1">
</div>
</div>
<div id="pricing-panel-length" class="pricing-panel">
<p style="color: var(--text-secondary); margin-top: 0;">
Tiers are evaluated by subname length. Leave max length blank on the last row for all longer names.
</p>
<table class="pricing-tier-table">
<thead>
<tr>
<th>Max length</th>
<th>Price (sats)</th>
<th></th>
</tr>
</thead>
<tbody id="pricing-tier-rows"></tbody>
</table>
<button type="button" class="secondary-btn" id="pricing-add-tier-btn" style="margin-top: 10px;">Add tier</button>
</div>
<div class="pricing-actions">
<button type="button" class="save-btn" id="pricing-save-btn">Save Pricing</button>
<label for="pricing-preview-subspace" style="font-weight: 500;">Preview subname</label>
<input type="text" id="pricing-preview-subspace" placeholder="alice" style="padding: 8px 10px; border: 1px solid #ddd; border-radius: 4px;">
<button type="button" class="secondary-btn" id="pricing-preview-btn">Preview price</button>
</div>
<div id="pricing-preview-result" class="pricing-preview" style="display: none;"></div>
</div>
<div class="config-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
@@ -410,6 +570,19 @@
// Check platform mode and apply appropriate theme
checkPlatformMode();
loadTenantConfig();
document.getElementById('pricing-save-btn').addEventListener('click', saveSubnamePricing);
document.getElementById('pricing-preview-btn').addEventListener('click', () => {
previewSubnamePricing().catch((error) => alert(error.message));
});
document.getElementById('pricing-add-tier-btn').addEventListener('click', () => {
const tiers = subnamePricing.length_tiers;
if (tiers.length > 0) {
tiers[tiers.length - 1].max_length = 3;
}
tiers.push({ max_length: null, price_sats: subnamePricing.base_price });
renderPricingTierRows();
});
// Set up home button navigation
const homeBtn = document.getElementById('home-btn');
@@ -453,6 +626,194 @@
// Store original config for cancel functionality
let originalConfig = {};
let currentConfig = {};
let subnamePricing = null;
const SUBNAME_PRICING_HIDDEN_KEYS = new Set([
'subname_pricing_mode',
'subname_base_price',
'subname_pricing_length_tiers',
]);
function sortConfigKeys(keys) {
return keys
.filter((key) => !SUBNAME_PRICING_HIDDEN_KEYS.has(key))
.sort((a, b) => a.localeCompare(b));
}
function renderPricingModeOptions() {
const container = document.getElementById('pricing-mode-list');
container.innerHTML = '';
if (!subnamePricing || !Array.isArray(subnamePricing.options)) {
return;
}
subnamePricing.options.forEach((option) => {
const label = document.createElement('label');
label.className = 'pricing-mode-option' + (subnamePricing.mode === option.id ? ' selected' : '');
const input = document.createElement('input');
input.type = 'radio';
input.name = 'subname-pricing-mode';
input.value = option.id;
input.checked = subnamePricing.mode === option.id;
const copy = document.createElement('div');
copy.className = 'pricing-mode-copy';
copy.innerHTML = `<strong>${option.label}</strong><span>${option.description}</span>`;
input.addEventListener('change', () => {
if (input.checked) {
subnamePricing.mode = option.id;
updatePricingPanels();
container.querySelectorAll('.pricing-mode-option').forEach((el) => el.classList.remove('selected'));
label.classList.add('selected');
}
});
label.appendChild(input);
label.appendChild(copy);
container.appendChild(label);
});
}
function updatePricingPanels() {
document.getElementById('pricing-panel-base').classList.toggle('active', subnamePricing.mode === 'base_price');
document.getElementById('pricing-panel-length').classList.toggle('active', subnamePricing.mode === 'length_tier');
}
function renderPricingTierRows() {
const tbody = document.getElementById('pricing-tier-rows');
tbody.innerHTML = '';
subnamePricing.length_tiers.forEach((tier, index) => {
const row = document.createElement('tr');
const maxCell = document.createElement('td');
const maxInput = document.createElement('input');
maxInput.type = 'number';
maxInput.min = '1';
maxInput.placeholder = index === subnamePricing.length_tiers.length - 1 ? 'default' : '';
maxInput.value = tier.max_length == null ? '' : tier.max_length;
maxInput.addEventListener('input', () => {
tier.max_length = maxInput.value === '' ? null : Number(maxInput.value);
});
maxCell.appendChild(maxInput);
const priceCell = document.createElement('td');
const priceInput = document.createElement('input');
priceInput.type = 'number';
priceInput.min = '0';
priceInput.value = tier.price_sats;
priceInput.addEventListener('input', () => {
tier.price_sats = Number(priceInput.value);
});
priceCell.appendChild(priceInput);
const actionCell = document.createElement('td');
if (index < subnamePricing.length_tiers.length - 1) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'secondary-btn';
removeBtn.textContent = 'Remove';
removeBtn.addEventListener('click', () => {
subnamePricing.length_tiers.splice(index, 1);
renderPricingTierRows();
});
actionCell.appendChild(removeBtn);
}
row.appendChild(maxCell);
row.appendChild(priceCell);
row.appendChild(actionCell);
tbody.appendChild(row);
});
}
function populateSubnamePricingForm(pricing) {
subnamePricing = JSON.parse(JSON.stringify(pricing));
document.getElementById('pricing-base-price').value = subnamePricing.base_price;
renderPricingModeOptions();
renderPricingTierRows();
updatePricingPanels();
}
async function loadSubnamePricing() {
const response = await fetch('/api/tenant/subname-pricing');
const data = await response.json();
if (checkAuthError(response, data)) {
return;
}
if (!data.success) {
throw new Error(data.message || 'Failed to load subname pricing');
}
populateSubnamePricingForm(data.pricing);
}
function collectSubnamePricingPayload() {
return {
mode: subnamePricing.mode,
base_price: Number(document.getElementById('pricing-base-price').value),
length_tiers: subnamePricing.length_tiers.map((tier, index, arr) => ({
max_length: index === arr.length - 1 ? null : tier.max_length,
price_sats: Number(tier.price_sats),
})),
};
}
async function saveSubnamePricing() {
const saveBtn = document.getElementById('pricing-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
try {
const payload = collectSubnamePricingPayload();
const response = await fetch('/api/tenant/subname-pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
if (checkAuthError(response, data)) {
return;
}
if (!data.success) {
throw new Error(data.message || 'Failed to save subname pricing');
}
populateSubnamePricingForm(data.pricing);
alert('Subname pricing saved successfully.');
} catch (error) {
alert('Error saving subname pricing: ' + error.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Pricing';
}
}
async function previewSubnamePricing() {
const subspace = document.getElementById('pricing-preview-subspace').value.trim();
const resultEl = document.getElementById('pricing-preview-result');
if (!subspace) {
alert('Enter a subname to preview.');
return;
}
const payload = collectSubnamePricingPayload();
payload.subspace = subspace;
const response = await fetch('/api/tenant/subname-pricing/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
if (checkAuthError(response, data)) {
return;
}
if (!data.success) {
throw new Error(data.message || 'Failed to preview subname pricing');
}
resultEl.style.display = 'block';
resultEl.textContent = `${data.preview.subspace}${data.preview.price} sats (${data.preview.mode})`;
}
async function loadTenantConfig() {
try {
@@ -487,8 +848,10 @@
if (!tenantData.success) {
throw new Error(tenantData.message || 'Failed to load tenant configuration');
}
await loadSubnamePricing();
// Store config for edit mode
// Store config for edit mode (server backfills missing keys from DEFAULT_SUBNAME_BASE_PRICE)
currentConfig = tenantData.configuration.config || {};
originalConfig = JSON.parse(JSON.stringify(currentConfig));
@@ -538,7 +901,7 @@
const container = document.getElementById('config-items-container');
container.innerHTML = '';
const keys = Object.keys(config).sort();
const keys = sortConfigKeys(Object.keys(config));
keys.forEach(key => {
const configItem = document.createElement('div');
@@ -565,7 +928,7 @@
value.appendChild(colorValue);
value.appendChild(colorPreview);
} else if (key === 'sptr_price') {
// Special handling for sptr_price - display with "sats" unit
// Special handling for sat-denominated prices
value.textContent = (config[key].value || 'Not set') + ' sats';
} else {
value.textContent = config[key].value || 'Not set';
@@ -578,7 +941,6 @@
}
function formatConfigKey(key) {
// Special handling for sptr_price
if (key === 'sptr_price') {
return 'SPTR Price';
}
@@ -593,14 +955,14 @@
const tbody = document.getElementById('config-table-body');
tbody.innerHTML = '';
const keys = Object.keys(config).sort();
const keys = sortConfigKeys(Object.keys(config));
keys.forEach(key => {
const row = document.createElement('tr');
const keyCell = document.createElement('td');
keyCell.className = 'config-key-cell';
keyCell.textContent = key;
keyCell.textContent = formatConfigKey(key);
const valueCell = document.createElement('td');
const input = document.createElement('input');
+416 -151
View File
@@ -15,6 +15,17 @@ const { BIP32Factory } = require('bip32');
const bitcoin = require('bitcoinjs-lib');
const ecc = require('tiny-secp256k1');
const { runTenantDDL } = require('./lib/tenantSchema');
const {
DEFAULT_SUBNAME_BASE_PRICE,
SUBNAME_PRICING_CONFIG_KEYS,
SUBNAME_PRICING_MODE_OPTIONS,
TENANT_SUBNAME_PRICING_DEFAULTS,
computeSubnamePrice: computeTenantSubnamePrice,
loadSubnamePricingSettings,
pricingSettingsToConfigEntries,
validateSubnamePricingPayload,
computeSubnamePriceFromSettings,
} = require('./lib/subnamePricing');
// Initialize ECC library for bitcoinjs-lib
bitcoin.initEccLib(ecc);
@@ -101,6 +112,8 @@ const FALLBACK_1_BLOCK_FEERATE_SAT_VB = 10;
const FALLBACK_6_BLOCK_FEERATE_SAT_VB = 3;
const FALLBACK_48_BLOCK_FEERATE_SAT_VB = 1;
// Default tenant subname base price (sats) — see lib/subnamePricing.js (DEFAULT_SUBNAME_BASE_PRICE)
// 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';
@@ -1847,10 +1860,11 @@ function onboardTenant(tenantName) {
('subs_uri', 'http://127.0.0.1:7244/'),
('spaces_mode', 'public'),
('revenue_split_percent', '5'),
('sptr_price', '10000')
('sptr_price', '10000'),
('subname_base_price', ?)
`;
tenantDb.run(insertConfigs, [randomBackgroundColor], (insertErr) => {
tenantDb.run(insertConfigs, [randomBackgroundColor, String(DEFAULT_SUBNAME_BASE_PRICE)], (insertErr) => {
if (insertErr) {
console.error(`Failed to insert initial config for ${tenantName}:`, insertErr.message);
tenantDb.close();
@@ -1914,12 +1928,49 @@ function offboardTenant(tenantName) {
});
}
/** Default config keys backfilled for tenants onboarded before a key existed. */
const TENANT_CONFIG_DEFAULTS = {
...TENANT_SUBNAME_PRICING_DEFAULTS,
};
function ensureTenantDefaultConfigKeys(tenantDb) {
return new Promise((resolve, reject) => {
const entries = Object.entries(TENANT_CONFIG_DEFAULTS);
if (entries.length === 0) {
resolve();
return;
}
let i = 0;
function insertNext() {
if (i >= entries.length) {
resolve();
return;
}
const [key, value] = entries[i++];
tenantDb.run(
'INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)',
[key, value],
(err) => {
if (err) {
reject(err);
return;
}
insertNext();
}
);
}
insertNext();
});
}
function getTenantConfiguration(tenantName) {
return new Promise(async (resolve, reject) => {
try {
// Ensure tenant exists and has proper config table
const ensuredTenantName = await ensureTenantExists(tenantName);
const tenantDb = await getTenantDatabase(ensuredTenantName);
await ensureTenantDefaultConfigKeys(tenantDb);
// Get all configuration values
tenantDb.all('SELECT key, value, created_at, updated_at FROM config ORDER BY key', (err, rows) => {
@@ -2701,6 +2752,8 @@ function ensureTenantExists(spaceName) {
);
});
await ensureTenantDefaultConfigKeys(tenantDb);
resolve(tenantName);
} catch (error) {
reject(error);
@@ -3879,8 +3932,67 @@ app.post('/spaces/:spaceName/:subspace', async (req, res) => {
}
confTargetForPurchase = 48;
} else {
price = bodyPrice;
block_fee = bodyBlockFee;
const quoteRow = await new Promise((resolve, reject) => {
tenantDb.get('SELECT * FROM quotes WHERE id = ?', [quote_id], (err, row) => {
if (err) {
reject(err);
} else {
resolve(row || null);
}
});
});
if (!quoteRow) {
return res.status(400).json({
success: false,
message: `Quote not found for quote_id ${quote_id}`,
});
}
if (quoteRow.handle !== handle) {
return res.status(400).json({
success: false,
message: 'handle does not match the quoted handle',
});
}
if (quoteRow.state === 'cancelled') {
return res.status(400).json({
success: false,
message: 'Quote has been cancelled',
});
}
if (quoteRow.state === 'taken') {
return res.status(400).json({
success: false,
message: 'Handle is no longer available for purchase',
});
}
const expectedBlockFee = quoteBlockFeeForConfTarget(quoteRow, confTargetNum);
if (expectedBlockFee === null || expectedBlockFee === undefined) {
return res.status(400).json({
success: false,
message: `Quote does not include block fee for conf_target ${confTargetNum}`,
});
}
if (Number(bodyBlockFee) !== Number(expectedBlockFee)) {
return res.status(400).json({
success: false,
message: `block_fee must match the quoted fee for conf_target ${confTargetNum}`,
});
}
price = quoteRow.price;
block_fee = expectedBlockFee;
if (bodyPrice !== undefined && bodyPrice !== null && Number(bodyPrice) !== Number(price)) {
console.log(
`[purchase] Subname purchase: using quoted price=${price} sats (request body price=${bodyPrice} ignored)`
);
}
}
// Validate coupon code if provided
@@ -6559,6 +6671,139 @@ app.post('/api/listnums-by-spk', async (req, res) => {
await handleListNumsBySpk(req, res, req.body?.script_pubkey);
});
function stripSpaceAtPrefix(spaceName) {
const s = String(spaceName).trim();
return s.startsWith('@') ? s.slice(1) : s;
}
function buildSubnameHandle(subspace, spaceName) {
return `${subspace}@${stripSpaceAtPrefix(spaceName)}`;
}
function isSubsHandleNotFoundError(parsedBody) {
if (!parsedBody || typeof parsedBody !== 'object') {
return false;
}
const err = parsedBody.error;
return typeof err === 'string' && err.toLowerCase().includes('handle not found');
}
/**
* Map SUBS handle lookup JSON to SpacesOps quote semantics.
* SUBS returns { error: "handle not found" } when a subname is available for sale.
* Any existing handle record (e.g. status "staged") means the name is taken.
*/
function parseSubsHandleLookupResponse(httpStatus, parsedBody, spaceName, subspace) {
if (isSubsHandleNotFoundError(parsedBody)) {
return {
availability: 'available',
responseData: {
name: subspace,
handle: buildSubnameHandle(subspace, spaceName),
state: 'available',
},
};
}
if (parsedBody && typeof parsedBody.name === 'string') {
const sanitized = { ...parsedBody };
delete sanitized.dev_private_key;
return {
availability: 'taken',
responseData: {
...sanitized,
handle: buildSubnameHandle(parsedBody.name, spaceName),
state: 'taken',
},
};
}
return {
availability: 'unknown',
responseData: parsedBody || {},
};
}
function getTenantConfigValueFromDb(tenantDb, key) {
return new Promise((resolve, reject) => {
tenantDb.get('SELECT value FROM config WHERE key = ?', [key], (err, row) => {
if (err) {
reject(err);
return;
}
resolve(row ? row.value : null);
});
});
}
async function computeSubnamePrice(tenantDb, context = {}) {
return computeTenantSubnamePrice(
tenantDb,
{
subspace: context.subspace || '',
spaceName: context.spaceName || '',
},
{ getTenantConfigValueFromDb }
);
}
function quoteBlockFeeForConfTarget(quoteRow, confTarget) {
if (confTarget === 1) {
return quoteRow['1_block_fee'];
}
if (confTarget === 6) {
return quoteRow['6_block_fee'];
}
if (confTarget === 48) {
return quoteRow['48_block_fee'];
}
return null;
}
function validateTenantPricingConfigKey(key, value) {
if (SUBNAME_PRICING_CONFIG_KEYS.includes(key)) {
throw new Error('Use POST /api/tenant/subname-pricing to update subname pricing settings');
}
if (key === 'sptr_price') {
const parsed = parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new Error(`${key} must be a non-negative integer (sats)`);
}
}
}
async function getTenantSubnamePricingResponse(tenantDb) {
await ensureTenantDefaultConfigKeys(tenantDb);
const settings = await loadSubnamePricingSettings(tenantDb, getTenantConfigValueFromDb);
return {
mode: settings.mode,
options: SUBNAME_PRICING_MODE_OPTIONS,
base_price: settings.base_price,
length_tiers: settings.length_tiers,
};
}
async function saveTenantSubnamePricing(tenantDb, payload) {
const settings = validateSubnamePricingPayload(payload);
const entries = pricingSettingsToConfigEntries(settings);
for (const [key, value] of Object.entries(entries)) {
await new Promise((resolve, reject) => {
tenantDb.run(
`INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
[key, value],
(err) => {
if (err) {
reject(err);
} else {
resolve();
}
}
);
});
}
return settings;
}
// [ROUTE: spaces-proxy] Anonymous proxy route: /spaces/:spaceName/:subspace
// Proxies requests to the tenant's subs_uri
app.all('/spaces/:spaceName/:subspace', async (req, res) => {
@@ -6572,107 +6817,6 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
});
}
// Handle lookups are SUBS-only (single source of truth) and use tenant-configured SUBS URI.
if (req.method === 'GET' || req.method === 'HEAD') {
const tenantName = getTenantNameFromSpaceName(spaceName.trim());
const tenantDb = await getTenantDatabase(tenantName);
const subsUri = await new Promise((resolve, reject) => {
tenantDb.get(
`SELECT value FROM config
WHERE key IN ('subs_uri', 'subsd_uri')
ORDER BY CASE key WHEN 'subs_uri' THEN 0 ELSE 1 END
LIMIT 1`,
(err, row) => {
if (err) {
reject(err);
return;
}
resolve(row ? row.value : null);
}
);
});
if (!subsUri) {
return res.status(500).json({
success: false,
message: 'subs_uri not configured for this space'
});
}
let baseSubsUri = subsUri.trim().replace(/\/$/, '');
try {
const url = new URL(baseSubsUri);
url.username = '';
url.password = '';
baseSubsUri = url.toString().replace(/\/$/, '');
} catch (_e) {
baseSubsUri = baseSubsUri.replace(/^https?:\/\/[^@]+@/, (match) => match.substring(0, match.indexOf('://') + 3));
}
const queryString = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const subsSpaceName = spaceName.startsWith('@') ? spaceName : `@${spaceName}`;
const proxiedPath = `/spaces/${encodeURIComponent(subsSpaceName)}/handles/${encodeURIComponent(subspace)}`;
const proxiedUrl = `${baseSubsUri}${proxiedPath}${queryString}`;
console.log(`Proxying request: ${req.method} ${req.url} -> ${proxiedUrl}`);
const proxyHeaders = {};
Object.keys(req.headers).forEach((key) => {
const lowerKey = key.toLowerCase();
if (lowerKey !== 'host' && lowerKey !== 'connection' && lowerKey !== 'content-length') {
proxyHeaders[key] = req.headers[key];
}
});
let proxyResponse;
try {
proxyResponse = await fetch(proxiedUrl, {
method: req.method,
headers: proxyHeaders,
});
} catch (fetchError) {
const causeCode = fetchError?.cause?.code;
if (fetchError.code === 'ECONNREFUSED' || causeCode === 'ECONNREFUSED') {
console.error(`[spaces-proxy] Connection refused to ${proxiedUrl}. Is the subs service running?`);
return res.status(503).json({
success: false,
message: `Unable to connect to subs service at ${baseSubsUri}. Please ensure the service is running.`,
error: 'ECONNREFUSED'
});
}
console.error(`[spaces-proxy] GET/HEAD proxy fetch failed: ${fetchError.message}`);
return res.status(502).json({
success: false,
message: `SUBS proxy request failed: ${fetchError.message}`,
errorCode: causeCode || fetchError.code || null
});
}
const responseBody = await proxyResponse.text();
res.status(proxyResponse.status);
const contentType = proxyResponse.headers.get('content-type') || '';
if (contentType) {
res.setHeader('Content-Type', contentType);
}
if (contentType.includes('application/json') || contentType.includes('text/json')) {
try {
const responseData = JSON.parse(responseBody);
if (!responseData.handle && typeof responseData.name === 'string') {
responseData.handle = `${responseData.name}@${spaceName}`;
}
if (!responseData.state && typeof responseData.status === 'string') {
const normalizedStatus = responseData.status.toLowerCase();
responseData.state = normalizedStatus === 'available' ? 'available' : 'taken';
}
return res.json(responseData);
} catch (_parseErr) {
// fall through to raw send below
}
}
return res.send(responseBody);
}
// Get tenant database for the space
const tenantName = getTenantNameFromSpaceName(spaceName.trim());
@@ -6815,20 +6959,25 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
// Modify response if it's JSON
let modifiedBody = responseBody;
const contentType = proxyResponse.headers.get('content-type') || '';
if (contentType.includes('application/json') || contentType.includes('text/json')) {
if (contentType.includes('application/json') || contentType.includes('text/json') || responseBody.trim().startsWith('{')) {
try {
const responseData = JSON.parse(responseBody);
const parsedSubs = JSON.parse(responseBody);
const lookup = parseSubsHandleLookupResponse(proxyResponse.status, parsedSubs, spaceName, subspace);
if (lookup.availability === 'unknown') {
console.log('[spaces-proxy] Unrecognized SUBS handle lookup response; forwarding unchanged');
} else {
res.status(200);
let responseData = lookup.responseData;
if (lookup.availability === 'taken') {
responseData.id = 0;
delete responseData.price;
console.log(`[spaces-proxy] Handle ${responseData.handle} is taken (SUBS status=${parsedSubs.status || 'n/a'})`);
modifiedBody = JSON.stringify(responseData);
} else {
console.log(`[spaces-proxy] Handle ${responseData.handle} is available for sale; computing tenant subname price`);
// New SUBS handle lookup responses use { name, status, ... }.
// Normalize to legacy fields expected by existing SpacesOps logic.
if (!responseData.handle && typeof responseData.name === 'string') {
responseData.handle = `${responseData.name}@${spaceName}`;
}
if (!responseData.state && typeof responseData.status === 'string') {
const normalizedStatus = responseData.status.toLowerCase();
responseData.state = normalizedStatus === 'available' ? 'available' : 'taken';
}
// Get RPC fee estimates first (used for both platform fees and SPTR fees)
let rpcEstimate1, rpcEstimate6, rpcEstimate48;
try {
@@ -6920,46 +7069,38 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
}
}
// Replace fee values in response only if SUBS values are LESS than platform values
// Apply platform minimum block fees (assign when SUBS omits them for available handles)
const originalFees = {
'1_block_fee': responseData['1_block_fee'],
'6_block_fee': responseData['6_block_fee'],
'48_block_fee': responseData['48_block_fee']
};
// Compare and replace only if original is less than platform value
if (fee1Block !== null && responseData['1_block_fee'] !== undefined) {
const originalValue = parseInt(responseData['1_block_fee'], 10);
if (originalValue < fee1Block) {
responseData['1_block_fee'] = fee1Block;
function applyPlatformBlockFee(fieldName, platformFee) {
if (platformFee === null) {
return;
}
const existingRaw = responseData[fieldName];
const existingValue =
existingRaw === undefined || existingRaw === null
? null
: parseInt(existingRaw, 10);
if (existingValue === null || Number.isNaN(existingValue) || existingValue < platformFee) {
responseData[fieldName] = platformFee;
feesReplaced = true;
console.log(`[spaces-proxy] 1_block_fee replaced: ${originalValue} < ${fee1Block} -> ${fee1Block}`);
console.log(
`[spaces-proxy] ${fieldName} set: ${existingValue ?? 'unset'} -> ${platformFee}`
);
} else {
console.log(`[spaces-proxy] 1_block_fee kept: ${originalValue} >= ${fee1Block} -> ${originalValue}`);
}
}
if (fee6Block !== null && responseData['6_block_fee'] !== undefined) {
const originalValue = parseInt(responseData['6_block_fee'], 10);
if (originalValue < fee6Block) {
responseData['6_block_fee'] = fee6Block;
feesReplaced = true;
console.log(`[spaces-proxy] 6_block_fee replaced: ${originalValue} < ${fee6Block} -> ${fee6Block}`);
} else {
console.log(`[spaces-proxy] 6_block_fee kept: ${originalValue} >= ${fee6Block} -> ${originalValue}`);
}
}
if (fee48Block !== null && responseData['48_block_fee'] !== undefined) {
const originalValue = parseInt(responseData['48_block_fee'], 10);
if (originalValue < fee48Block) {
responseData['48_block_fee'] = fee48Block;
feesReplaced = true;
console.log(`[spaces-proxy] 48_block_fee replaced: ${originalValue} < ${fee48Block} -> ${fee48Block}`);
} else {
console.log(`[spaces-proxy] 48_block_fee kept: ${originalValue} >= ${fee48Block} -> ${originalValue}`);
console.log(
`[spaces-proxy] ${fieldName} kept: ${existingValue} >= ${platformFee} -> ${existingValue}`
);
}
}
applyPlatformBlockFee('1_block_fee', fee1Block);
applyPlatformBlockFee('6_block_fee', fee6Block);
applyPlatformBlockFee('48_block_fee', fee48Block);
// Log fee replacements summary
if (feesReplaced) {
@@ -6972,6 +7113,11 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
console.log(`[spaces-proxy] Error retrieving platform fee configuration: ${configError.message}`);
console.log(`[spaces-proxy] Skipping fee replacement due to config error`);
}
responseData.price = await computeSubnamePrice(tenantDb, {
subspace,
spaceName,
});
// Check for pending purchase for this handle
if (responseData.handle) {
@@ -7140,14 +7286,14 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
}
}
// Convert back to JSON string
modifiedBody = JSON.stringify(responseData);
// Log modified response if fees were replaced
modifiedBody = JSON.stringify(responseData);
if (feesReplaced) {
console.log(`[spaces-proxy] Modified response:`);
console.log(` ${modifiedBody.substring(0, 500)}${modifiedBody.length > 500 ? '...' : ''}`);
}
}
}
} catch (parseError) {
console.log(`[spaces-proxy] Response is not valid JSON, skipping fee replacement: ${parseError.message}`);
@@ -7424,6 +7570,15 @@ app.post('/api/tenants/:tenantName/config', async (req, res) => {
});
}
try {
validateTenantPricingConfigKey(key, value);
} catch (validationError) {
return res.status(400).json({
success: false,
message: validationError.message,
});
}
const result = await setTenantConfiguration(tenantName, key, value);
res.json({
success: true,
@@ -7464,6 +7619,15 @@ app.post('/api/tenant/config', requireAdminOrTenant, setupTenantDatabase, async
});
}
try {
validateTenantPricingConfigKey(key, value);
} catch (validationError) {
return res.status(400).json({
success: false,
message: validationError.message,
});
}
const result = await setTenantConfiguration(req.tenantName, key, value);
res.json({
success: true,
@@ -7479,6 +7643,107 @@ app.post('/api/tenant/config', requireAdminOrTenant, setupTenantDatabase, async
}
});
app.get('/api/tenant/subname-pricing', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const tenantDb = await getTenantDatabase(req.tenantName);
const pricing = await getTenantSubnamePricingResponse(tenantDb);
res.json({ success: true, pricing });
} catch (error) {
console.error('Error getting tenant subname pricing:', error);
res.status(500).json({
success: false,
message: error.message,
});
}
});
app.get('/api/tenant/subname-pricing/preview', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const subspace = String(req.query.subspace || '').trim();
if (!subspace) {
return res.status(400).json({
success: false,
message: 'subspace query parameter is required',
});
}
const tenantDb = await getTenantDatabase(req.tenantName);
const price = await computeSubnamePrice(tenantDb, {
subspace,
spaceName: req.tenantName,
});
const settings = await loadSubnamePricingSettings(tenantDb, getTenantConfigValueFromDb);
res.json({
success: true,
preview: {
subspace,
mode: settings.mode,
price,
},
});
} catch (error) {
console.error('Error previewing tenant subname pricing:', error);
res.status(500).json({
success: false,
message: error.message,
});
}
});
app.post('/api/tenant/subname-pricing/preview', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const subspace = String(req.query.subspace || req.body?.subspace || '').trim();
if (!subspace) {
return res.status(400).json({
success: false,
message: 'subspace is required',
});
}
const settings = validateSubnamePricingPayload(req.body);
const price = computeSubnamePriceFromSettings(settings, { subspace });
res.json({
success: true,
preview: {
subspace,
mode: settings.mode,
price,
},
});
} catch (error) {
console.error('Error previewing tenant subname pricing:', error);
res.status(400).json({
success: false,
message: error.message,
});
}
});
app.post('/api/tenant/subname-pricing', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const tenantDb = await getTenantDatabase(req.tenantName);
const settings = await saveTenantSubnamePricing(tenantDb, req.body);
res.json({
success: true,
message: 'Subname pricing updated successfully',
pricing: {
mode: settings.mode,
options: SUBNAME_PRICING_MODE_OPTIONS,
base_price: settings.base_price,
length_tiers: settings.length_tiers,
},
});
} catch (error) {
console.error('Error saving tenant subname pricing:', error);
res.status(400).json({
success: false,
message: error.message,
});
}
});
// Coupon CRUD endpoints
app.get('/api/tenant/coupons', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {