feat: direct on-chain .sol domain registration with referral

This commit is contained in:
jae 2026-04-05 21:05:06 +00:00
parent 8d4dec5161
commit 77f2e526f4
3 changed files with 639 additions and 14 deletions

View file

@ -598,3 +598,225 @@
border-top: 1px solid rgba(255, 255, 255, 0.05);
margin-top: 0.25rem;
}
/* ─── Registration Modal ──────────────────────────────────── */
.sol-reg-modal-content {
max-width: 480px;
}
.sol-reg-summary {
padding: 0.5rem;
}
.sol-reg-domain-display {
text-align: center;
padding: 1.5rem 0 1.25rem;
border-bottom: 1px solid rgba(20, 241, 149, 0.15);
margin-bottom: 1rem;
}
.sol-reg-domain-name {
font-family: 'JetBrains Mono', monospace;
font-size: 1.4rem;
color: #ffffff;
letter-spacing: 3px;
}
.sol-reg-domain-ext {
font-family: 'JetBrains Mono', monospace;
font-size: 1.4rem;
color: #14F195;
letter-spacing: 3px;
}
.sol-reg-details {
margin-bottom: 1rem;
}
.sol-reg-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.55rem 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.sol-reg-row:last-child {
border-bottom: none;
}
.sol-reg-label {
font-family: 'JetBrains Mono', monospace;
font-size: 0.55rem;
color: rgba(255, 255, 255, 0.35);
letter-spacing: 2px;
}
.sol-reg-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: rgba(255, 255, 255, 0.8);
letter-spacing: 1px;
}
.sol-reg-price {
color: #14F195;
font-size: 0.75rem;
}
.sol-reg-warning {
background: rgba(255, 170, 0, 0.08);
border: 1px solid rgba(255, 170, 0, 0.2);
border-left: 3px solid rgba(255, 170, 0, 0.5);
padding: 0.75rem 1rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.5rem;
color: rgba(255, 170, 0, 0.85);
letter-spacing: 1px;
line-height: 1.6;
margin-bottom: 1rem;
}
.sol-reg-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding-top: 0.5rem;
}
.sol-reg-cancel-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.5);
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
letter-spacing: 2px;
padding: 0.65rem 1.5rem;
cursor: pointer;
transition: all 0.25s ease;
}
.sol-reg-cancel-btn:hover {
border-color: rgba(255, 50, 50, 0.4);
color: rgba(255, 50, 50, 0.7);
background: rgba(255, 50, 50, 0.08);
}
.sol-reg-confirm-btn {
background: linear-gradient(135deg, rgba(20, 241, 149, 0.25), rgba(168, 85, 247, 0.15));
border: 1px solid rgba(20, 241, 149, 0.5);
color: #ffffff;
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
letter-spacing: 2px;
padding: 0.65rem 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
}
.sol-reg-confirm-btn:hover {
background: linear-gradient(135deg, rgba(20, 241, 149, 0.4), rgba(168, 85, 247, 0.3));
border-color: #14F195;
box-shadow: 0 0 20px rgba(20, 241, 149, 0.2);
}
/* Registration Status */
.sol-reg-status {
margin-top: 1rem;
}
.sol-reg-status-card {
padding: 1rem;
border: 1px solid;
border-left-width: 3px;
}
.sol-reg-status-card.processing {
border-color: rgba(20, 241, 149, 0.3);
border-left-color: rgba(20, 241, 149, 0.6);
background: rgba(20, 241, 149, 0.04);
}
.sol-reg-status-card.pending {
border-color: rgba(255, 170, 0, 0.3);
border-left-color: rgba(255, 170, 0, 0.6);
background: rgba(255, 170, 0, 0.04);
}
.sol-reg-status-card.success {
border-color: rgba(20, 241, 149, 0.4);
border-left-color: #14F195;
background: rgba(20, 241, 149, 0.06);
}
.sol-reg-status-card.error {
border-color: rgba(255, 50, 50, 0.3);
border-left-color: rgba(255, 50, 50, 0.6);
background: rgba(255, 50, 50, 0.04);
}
.sol-reg-status-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.sol-reg-status-text {
font-family: 'JetBrains Mono', monospace;
font-size: 0.65rem;
color: #ffffff;
letter-spacing: 2px;
}
.sol-reg-status-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
}
.sol-reg-status-icon.success {
color: #14F195;
}
.sol-reg-status-icon.error {
color: rgba(255, 50, 50, 0.8);
}
.sol-reg-tx-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 0;
}
.sol-reg-tx-link {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: #14F195;
text-decoration: none;
letter-spacing: 1px;
}
.sol-reg-tx-link:hover {
text-decoration: underline;
}
.sol-reg-error-detail {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: rgba(255, 50, 50, 0.75);
letter-spacing: 0.5px;
line-height: 1.6;
margin: 0.5rem 0;
}
.sol-reg-success-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}

View file

@ -2,11 +2,16 @@
const SNS_API = 'https://sns-sdk-proxy.bonfida.workers.dev';
const SNS_REG = 'https://www.sns.id/domain/';
const SNS_REGISTER_API = 'https://sdk-proxy.sns.id';
const SOLSCAN = 'https://solscan.io/account/';
const SOLSCAN_TX = 'https://solscan.io/tx/';
const REFERRAL_WALLET = '9NuiHh5wgRPx69BFGP1ZR8kHiBENGoJrXs5GpZzKAyn8';
const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
let walletAddress = null;
let currentTab = 'search';
let pendingRegistrationDomain = null;
// ─── DOM ────────────────────────────────────────────────────
const $ = s => document.querySelector(s);
@ -16,6 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTabs();
initSearch();
initWallet();
createRegistrationModal();
});
// ─── TABS ───────────────────────────────────────────────────
@ -121,15 +127,7 @@ async function showTakenDomain(domain, owner) {
function showAvailableDomain(domain) {
const results = $('#search-results');
const len = domain.length;
let priceEstimate = '';
if (len <= 1) priceEstimate = '~750 USDC';
else if (len === 2) priceEstimate = '~700 USDC';
else if (len === 3) priceEstimate = '~640 USDC';
else if (len === 4) priceEstimate = '~160 USDC';
else priceEstimate = '~20 USDC';
const regUrl = `${SNS_REG}${encodeURIComponent(domain)}?ref=${REFERRAL_WALLET}`;
const price = getEstimatedPrice(domain);
results.innerHTML = `
<div class="sol-result-card">
@ -141,13 +139,13 @@ function showAvailableDomain(domain) {
<div>
<div class="sol-result-field">
<span class="sol-result-label">LENGTH</span>
<div class="sol-result-value">${len} character${len !== 1 ? 's' : ''}</div>
<div class="sol-result-value">${domain.length} character${domain.length !== 1 ? 's' : ''}</div>
</div>
<div class="sol-result-field">
<span class="sol-result-label">ESTIMATED COST</span>
<div class="sol-price">
<span class="sol-price-amount">${priceEstimate.split(' ')[0].replace('~','')}</span>
<span class="sol-price-currency">${priceEstimate.split(' ')[1] || 'USDC'}</span>
<span class="sol-price-amount">${price.amount.replace('~','')}</span>
<span class="sol-price-currency">${price.currency}</span>
<span class="sol-price-usd">(estimated)</span>
</div>
</div>
@ -155,13 +153,22 @@ function showAvailableDomain(domain) {
<div>
<div class="sol-result-field">
<span class="sol-result-label">REGISTRATION</span>
<div class="sol-result-value">Purchase your .sol domain</div>
<div class="sol-result-value">Register directly from your wallet</div>
</div>
<a href="${regUrl}" target="_blank" rel="noopener" class="sol-register-btn">REGISTER ${esc(domain.toUpperCase())}.SOL </a>
<div class="sol-result-field">
<span class="sol-result-label">PAYMENT</span>
<div class="sol-result-value">USDC on Solana</div>
</div>
<button class="sol-register-btn" id="register-domain-btn">REGISTER ${esc(domain.toUpperCase())}.SOL</button>
</div>
</div>
</div>
`;
const regBtn = results.querySelector('#register-domain-btn');
if (regBtn) {
regBtn.addEventListener('click', () => initiateRegistration(domain));
}
}
// ─── REVERSE LOOKUP ─────────────────────────────────────────
@ -496,6 +503,7 @@ async function connectWallet(wallet) {
walletAddress = accounts[0].address;
connectedProvider = wallet;
updateWalletUI();
checkPendingRegistration();
} else {
throw new Error('No accounts returned');
}
@ -505,6 +513,7 @@ async function connectWallet(wallet) {
walletAddress = resp.publicKey.toString();
connectedProvider = wallet;
updateWalletUI();
checkPendingRegistration();
}
} catch(err) {
console.error('Wallet connection failed:', err);
@ -627,3 +636,396 @@ function loadingHTML(msg) {
function errorHTML(msg) {
return `<div class="sol-error">ERROR // ${msg}</div>`;
}
// ─── REGISTRATION ───────────────────────────────────────────
function getEstimatedPrice(domain) {
const len = domain.length;
if (len <= 1) return { amount: '~750', currency: 'USDC', numeric: 750 };
if (len === 2) return { amount: '~700', currency: 'USDC', numeric: 700 };
if (len === 3) return { amount: '~640', currency: 'USDC', numeric: 640 };
if (len === 4) return { amount: '~160', currency: 'USDC', numeric: 160 };
return { amount: '~20', currency: 'USDC', numeric: 20 };
}
function createRegistrationModal() {
const modal = document.createElement('div');
modal.id = 'registration-modal';
modal.className = 'sol-modal hidden';
modal.innerHTML = `
<div class="sol-modal-backdrop"></div>
<div class="sol-modal-content sol-reg-modal-content">
<div class="sol-modal-header">
<span class="sol-modal-title">CONFIRM REGISTRATION</span>
<button class="sol-modal-close" id="reg-modal-close"></button>
</div>
<div class="sol-modal-body" id="reg-modal-body"></div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('.sol-modal-backdrop').addEventListener('click', closeRegistrationModal);
modal.querySelector('#reg-modal-close').addEventListener('click', closeRegistrationModal);
}
function initiateRegistration(domain) {
if (!walletAddress) {
pendingRegistrationDomain = domain;
openModal();
return;
}
showRegistrationModal(domain);
}
function checkPendingRegistration() {
if (pendingRegistrationDomain && walletAddress) {
const domain = pendingRegistrationDomain;
pendingRegistrationDomain = null;
setTimeout(() => showRegistrationModal(domain), 300);
}
}
function showRegistrationModal(domain) {
const modal = $('#registration-modal');
const body = $('#reg-modal-body');
const price = getEstimatedPrice(domain);
body.innerHTML = `
<div class="sol-reg-summary">
<div class="sol-reg-domain-display">
<span class="sol-reg-domain-name">${esc(domain)}</span><span class="sol-reg-domain-ext">.sol</span>
</div>
<div class="sol-reg-details">
<div class="sol-reg-row">
<span class="sol-reg-label">DOMAIN</span>
<span class="sol-reg-value">${esc(domain)}.sol</span>
</div>
<div class="sol-reg-row">
<span class="sol-reg-label">ESTIMATED COST</span>
<span class="sol-reg-value sol-reg-price">${price.amount} ${price.currency}</span>
</div>
<div class="sol-reg-row">
<span class="sol-reg-label">PAYMENT TOKEN</span>
<span class="sol-reg-value">USDC (Solana)</span>
</div>
<div class="sol-reg-row">
<span class="sol-reg-label">BUYER WALLET</span>
<span class="sol-reg-value">${truncAddr(walletAddress)}</span>
</div>
<div class="sol-reg-row">
<span class="sol-reg-label">STORAGE SPACE</span>
<span class="sol-reg-value">0 kB (minimum)</span>
</div>
</div>
<div class="sol-reg-warning" id="reg-warning">
THIS IS A REAL ON-CHAIN TRANSACTION. USDC WILL BE DEDUCTED FROM YOUR WALLET.
THE EXACT AMOUNT IS DETERMINED BY THE BONFIDA SNS SMART CONTRACT.
</div>
<div class="sol-reg-actions" id="reg-actions">
<button class="sol-reg-cancel-btn" id="reg-cancel">CANCEL</button>
<button class="sol-reg-confirm-btn" id="reg-confirm">CONFIRM & REGISTER</button>
</div>
</div>
<div class="sol-reg-status hidden" id="reg-status"></div>
`;
body.querySelector('#reg-cancel').addEventListener('click', closeRegistrationModal);
body.querySelector('#reg-confirm').addEventListener('click', () => executeRegistration(domain));
modal.classList.remove('hidden');
}
function closeRegistrationModal() {
const modal = $('#registration-modal');
if (modal) modal.classList.add('hidden');
}
function updateRegistrationStatus(state, message, extra) {
const statusEl = $('#reg-status');
if (!statusEl) return;
statusEl.classList.remove('hidden');
const actions = $('#reg-actions');
const warning = $('#reg-warning');
let icon, stateClass;
switch (state) {
case 'processing':
icon = '<div class="sol-loading-spinner"></div>';
stateClass = 'processing';
if (actions) actions.style.display = 'none';
if (warning) warning.style.display = 'none';
break;
case 'pending':
icon = '<div class="sol-loading-spinner"></div>';
stateClass = 'pending';
break;
case 'success':
icon = '<span class="sol-reg-status-icon success">✓</span>';
stateClass = 'success';
if (actions) actions.style.display = 'none';
if (warning) warning.style.display = 'none';
break;
case 'error':
icon = '<span class="sol-reg-status-icon error">✕</span>';
stateClass = 'error';
if (actions) { actions.style.display = 'flex'; }
if (warning) { warning.style.display = 'block'; }
break;
}
statusEl.innerHTML = `
<div class="sol-reg-status-card ${stateClass}">
<div class="sol-reg-status-header">
${icon}
<span class="sol-reg-status-text">${message}</span>
</div>
${extra || ''}
</div>
`;
}
async function executeRegistration(domain) {
if (!walletAddress || !connectedProvider) {
updateRegistrationStatus('error', 'WALLET NOT CONNECTED',
'<p class="sol-reg-error-detail">Please connect your wallet and try again.</p>');
return;
}
updateRegistrationStatus('processing', 'FETCHING TRANSACTION FROM SNS...');
try {
// 1. Fetch serialised transaction from SDK proxy
const params = new URLSearchParams({
buyer: walletAddress,
domain: domain,
space: '0',
serialize: 'true',
refKey: REFERRAL_WALLET,
mint: USDC_MINT
});
const apiUrl = `${SNS_REGISTER_API}/register?${params.toString()}`;
const res = await fetch(apiUrl);
if (!res.ok) {
throw new Error(`SNS API returned HTTP ${res.status}`);
}
const data = await res.json();
if (data.s !== 'ok' || !data.result) {
const errMsg = typeof data.result === 'string' ? data.result : (data.error || 'Unknown error from SNS API');
throw new Error(errMsg);
}
updateRegistrationStatus('processing', 'PREPARING TRANSACTION...');
// 2. Decode base64 transaction
const txBytes = base64ToUint8Array(data.result);
// 3. Deserialise — try VersionedTransaction first, fallback to legacy
let transaction;
let isVersioned = false;
try {
transaction = solanaWeb3.VersionedTransaction.deserialize(txBytes);
isVersioned = true;
} catch (e) {
try {
transaction = solanaWeb3.Transaction.from(txBytes);
isVersioned = false;
} catch (e2) {
throw new Error('Failed to deserialise transaction from SNS API');
}
}
// 4. Get fresh blockhash from Solana RPC
const connection = new solanaWeb3.Connection(SOLANA_RPC, 'confirmed');
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
// 5. Set fresh blockhash on transaction
if (isVersioned) {
transaction.message.recentBlockhash = blockhash;
} else {
transaction.recentBlockhash = blockhash;
}
updateRegistrationStatus('processing', 'AWAITING WALLET SIGNATURE...');
// 6. Sign and send via connected wallet
let signature;
if (connectedProvider.isWalletStandard) {
signature = await signAndSendWalletStandard(transaction, isVersioned, connection);
} else {
signature = await signAndSendLegacy(transaction, isVersioned, connection);
}
updateRegistrationStatus('pending', 'TRANSACTION SUBMITTED — CONFIRMING...',
`<div class="sol-reg-tx-info">
<span class="sol-reg-label">TX SIGNATURE</span>
<a href="${SOLSCAN_TX}${signature}" target="_blank" rel="noopener" class="sol-reg-tx-link">${truncAddr(signature)} </a>
</div>`);
// 7. Confirm transaction on-chain
const confirmation = await connection.confirmTransaction({
signature,
blockhash,
lastValidBlockHeight
}, 'confirmed');
if (confirmation.value.err) {
throw new Error('Transaction failed on-chain: ' + JSON.stringify(confirmation.value.err));
}
// 8. Success!
updateRegistrationStatus('success',
`${domain.toUpperCase()}.SOL REGISTERED SUCCESSFULLY!`,
`<div class="sol-reg-tx-info">
<span class="sol-reg-label">TRANSACTION</span>
<a href="${SOLSCAN_TX}${signature}" target="_blank" rel="noopener" class="sol-reg-tx-link">View on Solscan </a>
</div>
<div class="sol-reg-tx-info">
<span class="sol-reg-label">DOMAIN</span>
<a href="${SNS_REG}${encodeURIComponent(domain)}" target="_blank" rel="noopener" class="sol-reg-tx-link">View on SNS.id </a>
</div>
<div class="sol-reg-success-actions">
<button class="sol-reg-confirm-btn" onclick="closeRegistrationModal()">CLOSE</button>
</div>`);
} catch (err) {
console.error('Registration error:', err);
let userMessage = err.message || 'Unknown error';
// User-friendly error messages
if (/reject|denied|cancel|declined|disapproved/i.test(userMessage)) {
userMessage = 'Transaction was rejected by your wallet.';
} else if (/insufficient|not enough|0x1/i.test(userMessage)) {
userMessage = 'Insufficient USDC balance. Ensure you have enough USDC to cover the registration cost plus network fees.';
} else if (/already taken|already registered|already exists|registered/i.test(userMessage)) {
userMessage = 'This domain was just registered by someone else. Try a different name.';
} else if (/blockhash|expired|block height exceeded/i.test(userMessage)) {
userMessage = 'Transaction expired. Please try again.';
} else if (/network|fetch|failed to fetch|CORS/i.test(userMessage)) {
userMessage = 'Network error. Check your connection and try again.';
}
updateRegistrationStatus('error', 'REGISTRATION FAILED',
`<p class="sol-reg-error-detail">${esc(userMessage)}</p>
<div class="sol-reg-success-actions">
<button class="sol-reg-confirm-btn" id="reg-retry-btn">RETRY</button>
<button class="sol-reg-cancel-btn" onclick="closeRegistrationModal()">CLOSE</button>
</div>`);
// Bind retry after DOM update
const retryBtn = document.querySelector('#reg-retry-btn');
if (retryBtn) retryBtn.addEventListener('click', () => executeRegistration(domain));
}
}
async function signAndSendLegacy(transaction, isVersioned, connection) {
const provider = connectedProvider.provider;
// Try signAndSendTransaction first (Phantom, Solflare support this)
if (typeof provider.signAndSendTransaction === 'function') {
try {
const result = await provider.signAndSendTransaction(transaction, {
skipPreflight: false,
preflightCommitment: 'confirmed'
});
return result.signature || result;
} catch (e) {
if (/reject|denied|cancel|declined|disapproved/i.test(e.message || '')) throw e;
console.warn('signAndSendTransaction failed, falling back to signTransaction:', e.message);
}
}
// Fallback: signTransaction + manual send
if (typeof provider.signTransaction !== 'function') {
throw new Error('Wallet does not support transaction signing.');
}
const signed = await provider.signTransaction(transaction);
const rawTx = signed.serialize();
const signature = await connection.sendRawTransaction(rawTx, {
skipPreflight: false,
preflightCommitment: 'confirmed'
});
return signature;
}
async function signAndSendWalletStandard(transaction, isVersioned, connection) {
const wallet = connectedProvider.provider;
const account = wallet.accounts?.[0];
if (!account) throw new Error('No wallet account available.');
// Serialise the transaction for Wallet Standard
let txBytes;
if (isVersioned) {
txBytes = transaction.serialize();
} else {
txBytes = transaction.serialize({ requireAllSignatures: false, verifySignatures: false });
}
// Try signAndSendTransaction first
const signAndSendFeature = wallet.features?.['solana:signAndSendTransaction'];
if (signAndSendFeature) {
try {
const results = await signAndSendFeature.signAndSendTransaction({
account,
transaction: txBytes,
chain: 'solana:mainnet'
});
const result = Array.isArray(results) ? results[0] : results;
if (result.signature) {
if (typeof result.signature === 'string') return result.signature;
return uint8ArrayToBase58(new Uint8Array(result.signature));
}
return result;
} catch (e) {
if (/reject|denied|cancel|declined|disapproved/i.test(e.message || '')) throw e;
console.warn('WS signAndSendTransaction failed, falling back:', e.message);
}
}
// Fallback: signTransaction + manual send
const signFeature = wallet.features?.['solana:signTransaction'];
if (!signFeature) throw new Error('Wallet does not support Solana transaction signing.');
const results = await signFeature.signTransaction({
account,
transaction: txBytes,
chain: 'solana:mainnet'
});
const result = Array.isArray(results) ? results[0] : results;
const signedBytes = result.signedTransaction;
const signature = await connection.sendRawTransaction(signedBytes, {
skipPreflight: false,
preflightCommitment: 'confirmed'
});
return signature;
}
function base64ToUint8Array(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function uint8ArrayToBase58(bytes) {
const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
let result = '';
let num = BigInt(0);
for (const b of bytes) num = num * 256n + BigInt(b);
while (num > 0n) {
result = ALPHABET[Number(num % 58n)] + result;
num = num / 58n;
}
for (const b of bytes) {
if (b === 0) result = '1' + result;
else break;
}
return result || '1';
}

View file

@ -115,6 +115,7 @@
<script src="/js/nav.js"></script>
<script src="/js/clock.js"></script>
<script src="https://unpkg.com/@solana/web3.js@1.98.0/lib/index.iife.min.js"></script>
<script src="/js/soldomains.js"></script>
</body>
</html>