Files
linkspin/carry.js
T

161 lines
12 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) {
const emailOk = !(X.siteConfig && String(X.siteConfig().carryNotices || '1') === '0'); // test area: 0 = on-site notices only
try { if (emailOk && X.mailer && X.mailer.hasKey()) X.mailer.send(email, subject, text + '\n\nLinkSpin').catch(() => {}); } catch (e) {}
try {
const html = '<p>' + String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').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 };