010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
160 lines
11 KiB
JavaScript
160 lines
11 KiB
JavaScript
// Sponsor carry-over (LinkSpin, 2026-09-15). Marty's rule: a sponsor is never passed over for
|
|
// being absent, only for having no wallet.
|
|
// - At join: if the registry knows the email and no invite link was used, the member's
|
|
// InstantAdPay sponsor becomes their LinkSpin sponsor. If that sponsor has no LinkSpin
|
|
// account yet, a shell account is created for them (email, username, wallet from the
|
|
// registry) so the line exists on the site immediately; it becomes theirs the first time
|
|
// they sign in with that email. The sponsor is told.
|
|
// - Before a member's first activation or purchase: if the sponsor has a wallet but no
|
|
// position on this contract, the engine activates them on their behalf (activateFor,
|
|
// engine-signed, gas paid by the engine), walking up until it reaches someone already
|
|
// on-chain. The sponsor is told, and their first payout email follows on its own.
|
|
// - Sponsor with no wallet at all: the claim window. The member can do everything except
|
|
// buy; the sponsor has CLAIM_DAYS to link a wallet. After that the seat walks up to the
|
|
// nearest ancestor with a wallet and both sides are told.
|
|
let ethers = null; try { ethers = require('ethers'); } catch (e) {}
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
let X = {}; // { dataDir, accounts, registry, chain, mailer, messages, siteConfig, adminEmail }
|
|
const DAY = 86400000;
|
|
const ABI = ['function activateFor(address account, uint32 sponsorId) returns (uint32)', 'function engineSigner() view returns (address)'];
|
|
const norm = e => String(e || '').trim().toLowerCase();
|
|
|
|
function init(opts) { X = opts; }
|
|
function claimDays() { const n = Number((X.siteConfig && X.siteConfig().carryClaimDays) || 3); return n > 0 ? n : 3; }
|
|
const STATE = () => path.join(X.dataDir, 'carry-state.json');
|
|
function state() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')); } catch (e) { return { claims: {}, log: [] }; } }
|
|
function setState(s) { try { fs.writeFileSync(STATE(), JSON.stringify(s)); } catch (e) {} }
|
|
function logEvent(ev) { const s = state(); s.log = (s.log || []).concat([Object.assign({ ts: Date.now() }, ev)]).slice(-500); setState(s); }
|
|
|
|
function keyHex() {
|
|
let k = String(process.env.ENGINE_KEY || '').trim();
|
|
if (!k && process.env.ENGINE_KEY_FILE) { try { k = fs.readFileSync(process.env.ENGINE_KEY_FILE, 'utf8').trim(); } catch (e) {} }
|
|
if (!k) return null; if (!k.startsWith('0x')) k = '0x' + k;
|
|
return /^0x[0-9a-fA-F]{64}$/.test(k) ? k : null;
|
|
}
|
|
function engineReady() { return !!(ethers && keyHex()); }
|
|
function signer() {
|
|
const c = X.chain.getConfig(); const urls = (c.rpcs && c.rpcs.length) ? c.rpcs : [c.rpc];
|
|
const p = new ethers.JsonRpcProvider(urls[0], Number(c.chainId), { staticNetwork: true });
|
|
return new ethers.Wallet(keyHex(), p);
|
|
}
|
|
|
|
// ---- notices: email + on-site inbox, sent as the company account ----
|
|
async function tell(email, subject, text) {
|
|
try { if (X.mailer && X.mailer.hasKey()) X.mailer.send(email, subject, text + '\n\nLinkSpin').catch(() => {}); } catch (e) {}
|
|
try {
|
|
const html = '<p>' + String(text).replace(/&/g, '&').replace(/</g, '<').replace(/(https:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>').split('\n\n').join('</p><p>').replace(/\n/g, '<br>') + '</p>';
|
|
if (X.messages) await X.messages.deliver(1, X.adminEmail || 'house@linkspin.co', [email], subject, html);
|
|
} catch (e) {}
|
|
}
|
|
const label = a => a ? (a.username ? '@' + a.username : a.email.replace(/^(.).*(@.*)$/, '$1***$2')) : 'a member';
|
|
|
|
// ---- join: give the member their network sponsor if no link was used ----
|
|
async function onJoin(acct, usedRef) {
|
|
try {
|
|
const reg = await X.registry.get(acct.email);
|
|
// carry the member's own facts (username, wallet) forward when the site does not have them yet
|
|
if (reg && reg.username && !acct.username) { try { await X.accounts.setUsername(acct.email, reg.username); } catch (e) {} }
|
|
if (usedRef || !reg || !reg.sponsorEmail) return { carried: false };
|
|
const spReg = await X.registry.get(reg.sponsorEmail); if (!spReg) return { carried: false };
|
|
let sp = await X.accounts.byEmail(spReg.email);
|
|
if (!sp) {
|
|
// shell account for the sponsor: exists on the site now, becomes theirs at first sign-in
|
|
const r = await X.accounts.ensure(spReg.email, '', 'carried', 'network');
|
|
sp = r.account;
|
|
if (spReg.username && !sp.username) { try { await X.accounts.setUsername(spReg.email, spReg.username); sp = await X.accounts.byEmail(spReg.email); } catch (e) {} }
|
|
if (spReg.wallet && !sp.address) { try { await X.accounts.linkWallet(spReg.email, spReg.wallet); sp = await X.accounts.byEmail(spReg.email); } catch (e) {} }
|
|
// give the shell its own network sponsor too, so the line keeps climbing
|
|
if (spReg.sponsorEmail) { const up = await X.accounts.byEmail(spReg.sponsorEmail); const upReg = await X.registry.get(spReg.sponsorEmail); const tok = up ? (up.username || up.code) : (upReg && upReg.username); if (tok) await X.accounts.setSponsorRef(spReg.email, tok); }
|
|
}
|
|
const tok = sp.username || sp.code;
|
|
await X.accounts.setSponsorRef(acct.email, tok);
|
|
logEvent({ type: 'carried', member: acct.email, sponsor: sp.email });
|
|
await tell(sp.email, label(acct) + ' just joined LinkSpin under you',
|
|
'Your InstantAdPay referral ' + label(acct) + ' just joined LinkSpin, and the network placed them under you, the same sponsor line you already built.\n\n'
|
|
+ (sp.address ? 'Your LinkSpin position will be set up for you the moment it is needed, so you are paid from their first purchase whether or not you have signed in yet. ' : 'Link a wallet on LinkSpin within ' + claimDays() + ' days to be their sponsor on this property; without a wallet there is nothing for the contract to pay. ')
|
|
+ 'Sign in with this email to see your line: https://' + (X.host || 'linkspin.co') + '/my');
|
|
return { carried: true, sponsor: sp.email };
|
|
} catch (e) { return { carried: false, error: e.message }; }
|
|
}
|
|
|
|
// ---- before activation / purchase: make sure the sponsor exists on this contract ----
|
|
// returns { sponsorId, hold, claim } — hold is set when the buy must wait (claim window)
|
|
async function resolveForChain(acct, spd) {
|
|
if (!acct || !acct.sponsorRef) return null;
|
|
if (spd.id) return null; // already on-chain: nothing to do
|
|
if (spd.reason !== 'notActivated') return null; // unknown token or rpc: the normal guard handles it
|
|
let sp = await X.accounts.byCode(acct.sponsorRef); if (!sp) sp = await X.accounts.byUsername(acct.sponsorRef);
|
|
if (!sp) return null;
|
|
if (sp.address) {
|
|
const id = await ensureOnChain(sp, 0);
|
|
return id ? { sponsorId: id } : null;
|
|
}
|
|
// no wallet: the claim window
|
|
const st = state(); const k = norm(acct.email);
|
|
let c = st.claims[k];
|
|
if (!c) {
|
|
c = { member: k, sponsor: sp.email, opened: Date.now(), deadline: Date.now() + claimDays() * DAY, notified: true }; st.claims[k] = c; setState(st);
|
|
await tell(sp.email, label(acct) + ' is ready to buy on LinkSpin. Link a wallet to be their sponsor',
|
|
label(acct) + ' joined LinkSpin under you and wants to buy a package. The contract can only pay a wallet, and there is none on your LinkSpin account yet.\n\nLink one within ' + claimDays() + ' days (Wallet tab, one free signature) and their purchases pay you. If the window passes, the seat goes to the next person above you who has a wallet.\n\nhttps://' + (X.host || 'linkspin.co') + '/my#wallet');
|
|
}
|
|
if (Date.now() < c.deadline) return { sponsorId: 0, hold: true, claim: { sponsor: label(sp), deadline: c.deadline } };
|
|
// window passed: walk up to the nearest ancestor with a wallet, activate them, tell both sides
|
|
const up = await nearestWithWallet(sp.email);
|
|
if (!up) return { sponsorId: 0, hold: true, claim: { sponsor: label(sp), deadline: c.deadline, nobody: true } };
|
|
const id = await ensureOnChain(up, 0);
|
|
if (!id) return null;
|
|
await X.accounts.setSponsorRef(acct.email, up.username || up.code);
|
|
delete st.claims[k]; setState(st); logEvent({ type: 'walkedUp', member: acct.email, from: sp.email, to: up.email });
|
|
await tell(sp.email, 'The seat for ' + label(acct) + ' passed to ' + label(up), 'No wallet was linked within ' + claimDays() + ' days, so ' + label(acct) + ' was placed under ' + label(up) + ', the nearest person above you with a wallet. Link a wallet now and everyone who joins under you from here on is yours.');
|
|
await tell(up.email, label(acct) + ' was placed under you on LinkSpin', label(acct) + ' joined through ' + label(sp) + ', who has no wallet on LinkSpin, so the network placed them under you. Their purchases pay you from now on.');
|
|
return { sponsorId: id, movedTo: up.email };
|
|
}
|
|
|
|
async function nearestWithWallet(email) {
|
|
let cur = await X.accounts.byEmail(email); const seen = new Set([norm(email)]); let hops = 0;
|
|
while (cur && cur.sponsorRef && hops < 25) {
|
|
let sp = await X.accounts.byCode(cur.sponsorRef); if (!sp) sp = await X.accounts.byUsername(cur.sponsorRef);
|
|
if (!sp || seen.has(norm(sp.email))) return null; seen.add(norm(sp.email)); hops++;
|
|
if (sp.address) return sp; cur = sp;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// activate `acct` on this contract via the engine, after its own sponsor is there (recursive, bounded)
|
|
async function ensureOnChain(acct, depth) {
|
|
if (!acct || !acct.address) return 0;
|
|
try { const id = await X.chain.memberIdByAccount(acct.address); if (id) return id; } catch (e) {}
|
|
if (!engineReady() || depth > 25) return 0;
|
|
let sponsorId = 0;
|
|
if (acct.sponsorRef) {
|
|
let sp = await X.accounts.byCode(acct.sponsorRef); if (!sp) sp = await X.accounts.byUsername(acct.sponsorRef);
|
|
if (sp && sp.address) sponsorId = await ensureOnChain(sp, depth + 1);
|
|
}
|
|
if (!sponsorId) sponsorId = Number((X.siteConfig && X.siteConfig().defaultSponsorId) || 1) || 1;
|
|
try {
|
|
const w = signer(); const ctr = new ethers.Contract(X.chain.getConfig().contract, ABI, w);
|
|
const tx = await ctr.activateFor(acct.address, sponsorId, { gasLimit: 160000n });
|
|
await tx.wait(1);
|
|
const id = await X.chain.memberIdByAccount(acct.address);
|
|
logEvent({ type: 'activated', email: acct.email, id, sponsorId, tx: tx.hash });
|
|
await tell(acct.email, 'We set up your LinkSpin position so you would not miss a payout',
|
|
'Someone in your line was about to buy on LinkSpin, so the network activated your position on the LinkSpin contract for your wallet ' + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + ', bound to your own sponsor. Nothing was charged and nothing else was touched. Your share of that purchase lands in this wallet in the same transaction.\n\nSee your line: https://' + (X.host || 'linkspin.co') + '/my');
|
|
return id;
|
|
} catch (e) { logEvent({ type: 'activateFailed', email: acct.email, error: String(e.shortMessage || e.message).slice(0, 160) }); return 0; }
|
|
}
|
|
|
|
// pre-seed: activate every account with a wallet, sponsors before their downline
|
|
async function seedAll(limit) {
|
|
const all = await X.accounts.listAll(5000);
|
|
const withWallet = all.filter(a => a.address);
|
|
let done = 0, failed = 0;
|
|
for (const a of withWallet) { if (limit && done + failed >= limit) break; const id = await ensureOnChain(a, 0); if (id) done++; else failed++; }
|
|
return { candidates: withWallet.length, done, failed };
|
|
}
|
|
function status() { const s = state(); return { engine: engineReady(), claims: Object.values(s.claims || {}), log: (s.log || []).slice(-50).reverse(), claimDays: claimDays() }; }
|
|
|
|
module.exports = { init, onJoin, resolveForChain, ensureOnChain, seedAll, status, engineReady };
|