// 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 = '

' + String(text).replace(/&/g, '&').replace(/$1').split('\n\n').join('

').replace(/\n/g, '
') + '

'; 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 };