From 046464b83eae4b924ae8aab9ad497a2ecf121eb9 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 9 Sep 2026 17:18:47 -0500 Subject: [PATCH] Linked positions (Qualified Start): extra wallets on one account, credits pooled, buy-from picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - accounts/db: positions table + JSON store (add/list/owner/remove; main-wallet and cross-account guards) - auth verify: asPosition links a second wallet without touching the session; position wallets can't mint a session - /api/my/positions (chain-refreshed member ids, buyer counts, credits) + remove - credits pooled across main + positions on /api/me, dashboard, campaigns; campaign charged to the best-funded position - My line: own positions listed on level 1 as 'You · position N' - Buy pane: Qualified Start card + Add a position flow + Buy-from picker with connected-wallet guard - Wallet pane: Your positions card; chatbot answer updated Co-Authored-By: Claude Fable 5.1 --- accounts.js | 65 ++++++++++++++++++++++++++++- ads.js | Bin 66633 -> 67478 bytes chatbot.js | 2 +- db.js | 9 ++++ docs/TEAM-BUILDING-PLAYS.md | 80 ++++++++++++++++++++++++++++++++++++ public/assets/my.js | 78 ++++++++++++++++++++++++++++++++--- public/assets/wallet.js | 4 +- public/my.html | 25 +++++++++++ server.js | 78 +++++++++++++++++++++++++++++++++-- 9 files changed, 327 insertions(+), 14 deletions(-) create mode 100644 docs/TEAM-BUILDING-PLAYS.md diff --git a/accounts.js b/accounts.js index 4eff736..59f65a6 100644 --- a/accounts.js +++ b/accounts.js @@ -49,6 +49,7 @@ const J = { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {} if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 }; if (!this.db.byCode) this.db.byCode = {}; + if (!this.db.positions) this.db.positions = {}; for (const a of Object.values(this.db.byEmail)) { if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; } else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email; @@ -160,6 +161,7 @@ const J = { if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet ' + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' }; if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' }; + if (this.db.positions[a]) return { error: 'That wallet is already a linked position' + (this.db.positions[a].email === e ? ' on this account.' : ' on a different account.') }; acct.address = a; this.db.byAddress[a] = e; this.save(); @@ -172,7 +174,32 @@ const J = { acct.sponsorRef = ref; this.save(); return { ok: true, account: pub(acct) }; }, - async count() { return Object.keys(this.db.byEmail).length; } + async count() { return Object.keys(this.db.byEmail).length; }, + // ---- linked positions (extra wallets on one account) ---- + async positions(e) { + return Object.entries(this.db.positions).filter(([, p]) => p.email === e) + .map(([address, p]) => ({ address, email: p.email, memberId: p.memberId || 0, created: p.created })) + .sort((x, y) => x.created - y.created); + }, + async addPosition(e, a) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + if (!acct.address) return { error: 'Link your main wallet first.' }; + if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' }; + if (this.db.byAddress[a]) return { error: 'That wallet is already the main wallet of another account.' }; + const cur = this.db.positions[a]; + if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' }; + if (!cur) { this.db.positions[a] = { email: e, memberId: 0, created: Date.now() }; this.save(); } + return { ok: true, address: a, created: !cur }; + }, + async setPositionMember(a, id) { const p = this.db.positions[a]; if (p && p.memberId !== id) { p.memberId = id; this.save(); } }, + async positionOwner(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId || 0 } : null; }, + async removePosition(e, a) { + const p = this.db.positions[a]; + if (!p || p.email !== e) return { error: 'No such position.' }; + if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' }; + delete this.db.positions[a]; this.save(); return { ok: true }; + }, }; // ---- MySQL mode ---- @@ -278,6 +305,8 @@ const D = { if (!cur) return { error: 'No such account.' }; if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet ' + cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' }; + const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0]; + if (pos) return { error: 'That wallet is already a linked position' + (pos.email === e ? ' on this account.' : ' on a different account.') }; try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); } catch (err) { if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' }; @@ -291,7 +320,34 @@ const D = { if (!r.affectedRows) return { error: 'No such account.' }; return { ok: true, account: await this.byEmail(e) }; }, - async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); } + async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }, + // ---- linked positions (extra wallets on one account) ---- + async positions(e) { + const rows = await db.q('SELECT * FROM positions WHERE email=? ORDER BY created', [e]); + return rows.map(r => ({ address: r.address, email: r.email, memberId: r.member_id || 0, created: Number(r.created) })); + }, + async addPosition(e, a) { + const acct = await this.byEmail(e); + if (!acct) return { error: 'No such account.' }; + if (!acct.address) return { error: 'Link your main wallet first.' }; + if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' }; + if (await this.byAddress(a)) return { error: 'That wallet is already the main wallet of another account.' }; + const cur = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; + if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' }; + if (!cur) await db.q('INSERT INTO positions (address,email,member_id,created) VALUES (?,?,0,?)', [a, e, Date.now()]); + return { ok: true, address: a, created: !cur }; + }, + async setPositionMember(a, id) { await db.q('UPDATE positions SET member_id=? WHERE address=? AND member_id<>?', [id, a, id]); }, + async positionOwner(a) { + const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; + return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null; + }, + async removePosition(e, a) { + const r = (await db.q('SELECT * FROM positions WHERE address=? AND email=?', [a, e]))[0]; + if (!r) return { error: 'No such position.' }; + if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' }; + await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true }; + }, }; const impl = () => db.enabled() ? D : J; @@ -392,4 +448,9 @@ module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUs getMutes: e => impl().getMutes(String(e || '').toLowerCase()), setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m), sponsorOf, isDownlineOf, getChatSettings, + positions: e => impl().positions(normEmail(e)), + addPosition: (e, a) => { const x = normAddr(a); return /^0x[0-9a-f]{40}$/.test(x) ? impl().addPosition(normEmail(e), x) : Promise.resolve({ error: 'Bad wallet address.' }); }, + setPositionMember: (a, id) => impl().setPositionMember(normAddr(a), Number(id) || 0), + positionOwner: a => impl().positionOwner(normAddr(a)), + removePosition: (e, a) => impl().removePosition(normEmail(e), normAddr(a)), byMemberId: id => impl().byMemberId(id) }; diff --git a/ads.js b/ads.js index f98075960587231b2f86cc70c75b6d8f6fef0217..11f784b641ab89417d823a655e6890a1282f6d6c 100644 GIT binary patch delta 893 zcmYjQJ&zMH6x5-JB0{1;2)dzSS8VoDP$ddTP+d(KQh04AYvI@{+k4@Zi&AnO#Yy}Q zq&tb4f`aC{`yu=Tp1t|FRF3^--n@CUf3JE!zW09qx!sz6qSMNJA+E-H6_hnTA0g%IAYJw$GBK0qvyKALB#fOir7^*9j6l0tA1>I6k=XRiDlLx+@-a4KMIb{m zNmQHu7W5-6Wz+gPTkrfm6C*?$x(=IsOtLoz>D~4w2}Z-UTxOXm(BzoB11%m+nScd` z3E2%C_eo-7FRvA0^3q#3rFmI_Dq*V*hXGi3%BRCwwBn(z;ZD{FLu{fjCj;%2YLYJE zqDG{K{Qdpy`OW?9idD#Vs{t)f{JO)MKL4b}KN%7icoh)5Z6zIEPfua?Y o=?PiuKEHk3<>FU(baH?4A4l>qPyhe` delta 44 zcmV+{0Mq}Lj|9nx1hB=lv!%Bv8Iv2h3 Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' }, { re: /(need (a )?wallet|crypto experience|metamask|how (do i|to) join|sign ?up|register)/i, a: 'Join free with just your email at https://instantadpay.com/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' }, { re: /(referral link|invite link|share link|refer)/i, diff --git a/db.js b/db.js index 521f7db..7770e74 100644 --- a/db.js +++ b/db.js @@ -120,6 +120,15 @@ async function bootstrap() { await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day) await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged + // linked positions: extra wallets owned by one email account (Qualified Start). + // Each is its own on-chain member sponsored by the account's main member. + await q(`CREATE TABLE IF NOT EXISTS positions ( + address VARCHAR(64) PRIMARY KEY, + email VARCHAR(190) NOT NULL, + member_id INT NOT NULL DEFAULT 0, + created BIGINT NOT NULL, + INDEX (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS visit_seen ( campaign_id INT NOT NULL, email VARCHAR(190) NOT NULL, diff --git a/docs/TEAM-BUILDING-PLAYS.md b/docs/TEAM-BUILDING-PLAYS.md new file mode 100644 index 0000000..c30e974 --- /dev/null +++ b/docs/TEAM-BUILDING-PLAYS.md @@ -0,0 +1,80 @@ +# InstantAdPay team-building plays + +Working doc, 2026-09-09. Three ways to build a line, written so a member can pick one and run it. Every number below comes from the live contract and rate table (50 / 20 / 10 / 20 split; qualifying buyer = a direct who buys a $20+ package; level 2 opens at 2 qualifying, level 3 at 5; milestone credits 10 / 25 / 50 / 100; featured strip 40 credits a day; one broadcast a day; wall position 2 at 2 qualifying, position 3 at 5). + +## The one rule under all three plays + +Unqualified levels pass up. If someone on your level 2 buys before you have 2 qualifying buyers, that 20% does not wait for you. It goes to the next qualified sponsor above you (or the platform). Same for level 3 and 5. So whatever play you run, the first job is the same: get qualified before your line gets busy. The dashboard "Your next move" card is the ladder; the plays are how you climb it. + +## Play 1: Wide and teach ("the fifty play") + +Who it fits: someone with an audience, a list, a group, or traffic they can point somewhere. Time-rich or reach-rich. + +Why it works: every direct who buys is 50% to you, instantly, forever. Directs are the only thing that qualifies you. And the teaching is what fills levels 2 and 3 without you doing anything extra: your directs' buyers are your 20%, their buyers are your 10%. + +The move: +1. One new conversation a day, minimum. Use the Text a friend and Social posts in Promo tools; every piece already carries your link. +2. Point paid traffic at an angle lander, not the bare link: `?v=adspend` for advertisers, `?v=free` for freebie seekers, `?v=instant` for the crypto-curious. +3. Every new direct gets the same three sentences from you inside 24 hours (sponsor chat or one broadcast): "Pick your username. Link your wallet and switch on payouts. Send your link to one person today." That is the whole teaching. They teach it to their people. +4. Run the network's own ads at your link: buy a package, or claim the daily 5 credits, and spend the credits on a Featured link (40 credits a day) or a text ad pointed at your angle lander. Recruits from inside the network already understand the product. + +Scoreboard: Joined your line (should climb daily), Qualifying buyers (2 then 5), then watch level 2 and 3 rows appear in My line. + +Ceiling: none on width. Weakness: shallow lines churn if you skip step 3. + +## Play 2: Two, then down ("the depth play") + +Who it fits: someone with a small circle who would rather coach two people well than pitch twenty. + +Why it works: two qualifying buyers open level 2 (20%), position 2 on your wall, the Circuit badge, and 50 bonus credits. From there, every person your two bring in pays you 20%, and every person those people bring in pays you 10% (once you reach 5). Your effort goes into two relationships instead of a funnel. + +The move: +1. Get two directs to a $20+ package. Sit with them on the buy if you have to (Trust Wallet needs a POL cushion; SafePal or MetaMask are smoother). +2. Coach them to their two. Sponsor chat daily for the first week. One broadcast a day to your directs with a single ask each time. +3. Set your line banner to your team's meeting place (Telegram group, a training page). Every new member three levels down meets it on their welcome tour. That is how your coaching reaches people you never directly recruited. +4. Keep adding directs until you have five. This is the catch in the depth play: level 3 only opens on five qualifying directs of your own. Two deep, coached well, gets you a healthy 20% level. It does not get you the 10% level. + +Scoreboard: Qualifying buyers 2, then level 2 count in My line rising, then your directs' own Qualifying buyers (ask them, or read their wall page). + +Ceiling: level 2 income until you personally hit five. Strength: the stickiest lines come from this play. + +## Play 3: Five and wide ("the combination", recommended default) + +Who it fits: anyone willing to do both. This is the play the dashboard ladder is actually built for. + +The move, in order: +1. Sprint to five qualifying directs. Nothing else matters until level 3 is open: that is Nexus, wall position 3 (your whole public page runs your own links), 100 bonus credits, and the full 50/20/10. +2. Then split the day. Mornings wide: one new conversation, one post, one ad running. Evenings deep: read My line, message the three newest directs, send the broadcast. +3. Coach the 2-then-5 rule down the line. Each of your five gets pushed to two (your level 2 fills), then to five (your level 3 fills). Use the achievements Share links; people copy what they see rewarded. +4. Book the featured strip for 7 days whenever you have 280 credits spare. Ten slots a day, every member sees it. + +Scoreboard: all four tiles, plus Earning levels on the Overview (buyers referred, level open, how many to next). + +## Which play fits you + +| You have | Run | +|---|---| +| A list, a group, or ad budget | Wide and teach | +| A few close people and patience | Two, then down | +| An hour a day and a phone | Five and wide | + +## First 30 days (any play) + +- Day 1: username, wallet linked, payouts on, welcome tour done (25 credits). Send the link to one person. +- Days 2 to 7: one conversation a day. Claim the daily 5 credits. First buyer (25 bonus credits). +- Days 8 to 14: second qualifying buyer. Level 2 open. Set your line banner. First broadcast. +- Days 15 to 30: coach the two to their two. Add directs three, four, five. Level 3 open by day 30 is the stretch goal. + +## Messages that fit the plays + +- Wide: "I run ads anyway. This one pays me in the same transaction the buyer's package sells, on a public ledger. Free to join by email: {{link}}" +- Depth: "I need two people who will actually do this with me, not twenty who will look at it. You are one of the two I thought of. {{link}}" +- Combo, to a new direct: "Three things today: username, wallet on, one person. I will check in tomorrow." + +No income is guaranteed. Results depend on your effort. Crypto involves risk of loss. InstantAdPay sells advertising; it is not an investment. + +## Where this should live on the site (proposal, not built) + +- Promo tools: a "Plays" pill with the three plays and the fit table. +- Sponsor coaching panel (pending build): the coach sees which play each direct is on, from their buyer count and line shape. +- Training: a short video per play once the plays are approved. diff --git a/public/assets/my.js b/public/assets/my.js index ae50681..ec5c9ae 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -500,6 +500,7 @@ $('posLine').innerHTML = who.join('
'); $('creditLine').textContent = (me.credits || 0).toLocaleString(); + loadPositions(me); $('linkCard').hidden = !!me.address; $('activateCard').hidden = !(me.address && !me.memberId); $('activityArea').hidden = !me.memberId; @@ -956,7 +957,7 @@ } el.innerHTML = r.levels.map(L => !L.members.length ? '' : '
Level ' + L.level + ' · ' + L.members.length + (L.level === 1 ? ' direct' : '') + '
' - + L.members.map(m => '
' + esc(m.name) + '' + + L.members.map(m => '
' + esc(m.name) + (m.own ? ' yours' : '') + '' + (m.email ? '' + esc(m.email) + '' : '#' + m.memberId + '') + '' + (m.earnedWei && m.earnedWei !== '0' ? '+' + IAP.fmtPol(m.earnedWei) + ' POL' : '0.00 POL') + '' @@ -1205,6 +1206,57 @@ } } catch (e) { IAP.status('Could not open MoonPay: ' + ((e && e.message) || e), 'bad'); } } + // ── linked positions (Qualified Start) ── + const short = a => a ? a.slice(0, 6) + '…' + a.slice(-4) : ''; + async function loadPositions(me) { + try { + const r = await (await fetch('/api/my/positions')).json(); + if (r.error) return; + const list = r.positions || []; + const rows = list.map((p, i) => '
Position ' + (i + 2) + ' ' + short(p.address) + '' + + '' + (p.memberId ? '#' + p.memberId : 'not on-chain yet') + '' + + '' + (p.counted ? 'counts as a qualifying buyer' : p.memberId ? 'registered, buy $20+ to count' : 'buy a $20+ package to register it') + '' + + '' + (p.credits || 0).toLocaleString() + ' credits' + + (!p.memberId ? '' : '') + + '
').join(''); + const mainRow = r.main && r.main.address ? '
Position 1 · main ' + short(r.main.address) + '' + + '' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '' + + '' + (r.main.buyerCount || 0) + ' qualifying buyer(s)' + + '' + (r.main.credits || 0).toLocaleString() + ' credits
' : ''; + const html = mainRow + rows + (list.length ? '

Pooled credits: ' + (r.totalCredits || 0).toLocaleString() + '. A campaign budget spends from one position at a time.

' : ''); + if ($('qsList')) $('qsList').innerHTML = html; + if ($('posList')) $('posList').innerHTML = html; + if ($('posCard')) $('posCard').hidden = !list.length; + // "Buy from" picker: main + every position + const sel = $('buyFrom'); + if (sel) { + const keep = sel.value; + sel.innerHTML = '' + + list.map((p, i) => '').join(''); + if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep; + $('buyFromWrap').hidden = !list.length; + } + document.querySelectorAll('[data-unlink]').forEach(b => b.addEventListener('click', async () => { + if (!confirm('Unlink ' + short(b.dataset.unlink) + ' from your account?')) return; + try { await api('/api/my/positions/remove', { address: b.dataset.unlink }); IAP.status('Position unlinked.', 'ok'); loadPositions(); } + catch (e) { IAP.status(e.message, 'bad'); } + })); + } catch (e) {} + } + if ($('qsAddBtn')) $('qsAddBtn').addEventListener('click', busy2($('qsAddBtn'), async () => { + const me = await (await fetch('/api/me')).json(); + if (!me.address) throw new Error('Link your main wallet first (Wallet tab), then add positions under it.'); + if (!me.memberId) throw new Error('Switch on payouts for your main wallet first (Wallet tab). Positions register under your member number.'); + if (!confirm('In your wallet app, switch to a DIFFERENT account than ' + short(me.address) + ' first (MetaMask: account menu, Add account. Trust or SafePal: switch wallet). The wallet picker will open again so you can connect that account.\n\nReady?')) return; + await IAPWallet.disconnect(); + IAP.status('Connect the new account in the picker, then sign once…'); + const r = await IAPWallet.signIn({ asPosition: true }); + $('qsHint').textContent = 'Added ' + short(r.address) + '. Now choose it under "Buy from" and buy a $20 or larger package.'; + IAP.status('Position added: ' + short(r.address) + '. Pick it under "Buy from" above and buy a $20+ package to count it.', 'ok'); + await loadPositions(); + const sel = $('buyFrom'); if (sel) sel.value = r.address.toLowerCase(); + try { $('buyFrom').scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} + })); async function loadBuyTiles() { try { const { products } = await (await fetch('/api/catalog')).json(); @@ -1233,17 +1285,33 @@ // fetches when the page returns from the wallet app-switch const jretry = async url => { let err; for (let i = 0; i < 4; i++) { try { return await (await fetch(url)).json(); } catch (e) { err = e; await new Promise(s => setTimeout(s, 500 * (i + 1))); } } throw err; }; const meNow = await jretry('/api/me'); - if (!meNow.address) { + // which of the member's wallets is buying: the main wallet (default) or a + // linked position (Qualified Start). A position registers under the main + // member id on its first buy, so its sponsor is always this member. + const fromSel = $('buyFrom'); + const fromPos = (fromSel && !$('buyFromWrap').hidden && fromSel.value && fromSel.value !== 'main') ? fromSel.value.toLowerCase() : null; + if (fromPos && !meNow.memberId) { IAP.status('Switch on payouts for your main wallet first (Wallet tab), so this position can register under you.', 'bad'); return; } + if (!fromPos && !meNow.address) { IAP.status('Link your wallet first — one quick signature…'); await IAPWallet.signIn(); } - const spNow = await jretry('/api/sponsor'); + const wantAddr = fromPos || (meNow.address ? meNow.address.toLowerCase() : null); + if (wantAddr) { // never buy from a wallet other than the one selected: a stray wallet would register a brand-new member + let cur = (IAPWallet.address() || await IAPWallet.connect() || '').toLowerCase(); + if (cur !== wantAddr) { + IAP.status('Switch your wallet app to ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + ', then pick it in the picker…'); + await IAPWallet.disconnect(); + cur = String(await IAPWallet.connect() || '').toLowerCase(); + } + if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.'); + } + const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor'); // pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any // transaction that spends most of the balance ("drain your wallet"), so Trust users // get a heads-up first; other wallets go straight to the confirmation. try { const need = BigInt(b.dataset.cost) + BigInt(b.dataset.cost) / 50n; // same 2% pad as buy() - const bal = await IAPWallet.balance(IAPWallet.address() || meNow.address); + const bal = await IAPWallet.balance(wantAddr || IAPWallet.address() || meNow.address); if (bal < need) { IAP.status('That wallet holds ' + IAP.fmtPol(bal.toString()) + ' POL, but this package needs about ' + IAP.fmtPol(need.toString()) + ' POL plus a little for gas. Top it up and try again.', 'bad'); return; @@ -1258,7 +1326,7 @@ IAP.status('Confirm the purchase in your wallet…'); const r = await IAPWallet.buy(Number(b.dataset.id), spNow.sponsorId || 0, b.dataset.cost); if (r.status !== '0x1' && r.receipt && r.receipt.status !== '0x1') throw new Error('Transaction reverted.'); - IAP.status('Purchase settled on-chain. Credits are in your account.', 'ok'); + IAP.status(fromPos ? 'Purchase settled on-chain. That position now counts toward your qualification, and its credits pool with yours.' : 'Purchase settled on-chain. Credits are in your account.', 'ok'); await render(); loadBuyTiles(); } catch (e) { IAP.status('Purchase failed: ' + ((e && e.message) || e), 'bad'); } diff --git a/public/assets/wallet.js b/public/assets/wallet.js index bca3dd7..a945e1b 100644 --- a/public/assets/wallet.js +++ b/public/assets/wallet.js @@ -148,7 +148,7 @@ window.IAPWallet = (function () { } // SIWE: challenge -> personal_sign -> verify (server sets the session cookie) - async function signIn() { + async function signIn(opts) { const addr = await connect(); const ch = await (await fetch('/api/auth/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr }) })).json(); @@ -157,7 +157,7 @@ window.IAPWallet = (function () { const hexMsg = '0x' + Array.from(new TextEncoder().encode(ch.message)).map(b => b.toString(16).padStart(2, '0')).join(''); const sig = await eth().request({ method: 'personal_sign', params: [hexMsg, addr] }); const r = await (await fetch('/api/auth/verify', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig }) })).json(); + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: addr, signature: sig, asPosition: !!(opts && opts.asPosition) }) })).json(); if (r.error) throw new Error(r.error); return r; } diff --git a/public/my.html b/public/my.html index ac0a40f..caab69e 100644 --- a/public/my.html +++ b/public/my.html @@ -324,8 +324,27 @@ to this account instantly, and every payout in your sponsor line lands the moment you confirm.

Using Trust Wallet? It blocks purchases that spend most of the POL in the wallet (a "drain your wallet" warning). Start with a smaller package, keep a little extra POL, or connect MetaMask, Phantom or SafePal instead. Extra POL always stays yours.

+
Loading live prices…
+
+

Qualified Start: your own positions

+

Qualification is earned by buyers you refer, and it is never for sale. But nothing stops you from being your own first buyers, openly. Link a second wallet of your own as a position under your account. When it buys a $20 or larger package, the contract counts it as a qualifying buyer, half the purchase comes straight back to your main wallet, and the credits it mints pool with yours. Two positions open level 2 the same day. Five open level 3.

+
    +
  1. Link your main wallet and switch on payouts (Wallet tab) so positions can register under you.
  2. +
  3. In your wallet app, create or switch to a different account (MetaMask: account menu, Add account. Trust or SafePal: switch wallet). Put enough POL in it for a $20 package plus gas.
  4. +
  5. Click Add a position, pick that account in the picker, and sign once.
  6. +
  7. Choose it under Buy from above and buy a $20 or larger package.
  8. +
+

+

+
+

Your own money, your own wallets, a faster start. It is not an income promise: qualification only pays on future purchases in your line, and every purchase here is a real ad package you can spend.

+
+

Your account

…

diff --git a/server.js b/server.js index 36b3358..0ba611c 100644 --- a/server.js +++ b/server.js @@ -87,6 +87,14 @@ async function uplineSlides(email, depth = 3) { // thresholds that open payout levels 2 and 3). Until then, or while an unlocked // slot is empty, the slot shows an upline's banner, then a house ad. const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1); +// every on-chain member id this session controls: the main wallet plus linked +// positions (Qualified Start). Credits pool across them on the dashboard. +async function myMemberIds(s) { + const main = await auth.refreshMemberId(s); + const ids = main ? [main] : []; + if (s && s.email) for (const p of await accounts.positions(s.email)) if (p.memberId && !ids.includes(p.memberId)) ids.push(p.memberId); + return { main, ids }; +} function parseWallOffers(a) { try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; } } @@ -653,6 +661,15 @@ const server = http.createServer(async (req, res) => { let memberId = 0; try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {} const s = await auth.fromRequest(req); + if (s && s.email && b.asPosition) { + // Qualified Start: a second (third…) wallet on the same account. It becomes + // its own on-chain member under this member's id when it buys; the session + // stays on the main wallet. + const pr = await accounts.addPosition(s.email, r.address); + if (pr.error) return json(res, 400, pr); + if (memberId) await accounts.setPositionMember(r.address, memberId); + return json(res, 200, { ok: true, position: true, address: r.address, memberId }); + } if (s && s.email) { const lr = await accounts.linkWallet(s.email, r.address); if (lr.error) return json(res, 400, lr); @@ -660,6 +677,8 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, linked: true, address: r.address, memberId }); } const acct = await accounts.byAddress(r.address); + if (!acct && await accounts.positionOwner(r.address)) + return json(res, 400, { error: 'That wallet is a linked position on an account. Sign in with that account\'s email instead.' }); const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId }); return json(res, 200, { ok: true, address: r.address, memberId }, { 'Set-Cookie': auth.sessionCookie(token) }); @@ -696,7 +715,7 @@ const server = http.createServer(async (req, res) => { const mm = await chain.member(memberId); out.buyerCount = mm.buyerCount; out.onchainSponsorId = mm.sponsorId; - out.credits = await chain.creditBalance(memberId, 0); + out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total; } catch (e) { out.chainReadError = true; } } return json(res, 200, out); @@ -745,7 +764,7 @@ const server = http.createServer(async (req, res) => { try { const mm = await chain.member(memberId); out.buyerCount = mm.buyerCount; - out.credits = await ads.availableCredits(memberId); + out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total; } catch (e) { out.chainReadError = true; } let earned = 0n, n = 0; for (const ev of chain.recentEvents(600)) { @@ -784,6 +803,38 @@ const server = http.createServer(async (req, res) => { out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own return json(res, 200, out); } + // -- linked positions (Qualified Start): list, refresh from chain, unlink + if (p === '/api/my/positions' && req.method === 'GET') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const acct = await accounts.byEmail(s.email); + const mainId = await auth.refreshMemberId(s); + const list = await accounts.positions(s.email); + const out = []; + for (const pos of list) { + let id = pos.memberId; + if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} } + const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created }; + if (id) { + try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {} + try { row.credits = await ads.availableCredits(id); } catch (e) {} + } + out.push(row); + } + const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 }; + if (mainId) { + try { main.credits = await ads.availableCredits(mainId); } catch (e) {} + try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {} + } + return json(res, 200, { main, positions: out, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) }); + } + if (p === '/api/my/positions/remove' && req.method === 'POST') { + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + const b = await readBody(req); + const r = await accounts.removePosition(s.email, b.address); + return json(res, r.error ? 400 : 200, r); + } if (p === '/api/my/profile' && req.method === 'POST') { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); @@ -898,6 +949,14 @@ const server = http.createServer(async (req, res) => { email: L.level === 1 ? m.email : null, // directs only joined: m.created, earnedWei: (m.memberId && earnedBy[m.memberId]) || '0' })) })); + // the member's own linked positions sit on level 1 too, labelled as theirs + const own = (await accounts.positions(s.email)).filter(p => p.memberId); + if (own.length) { + if (!out.find(L => L.level === 1)) out.unshift({ level: 1, members: [] }); + const L1 = out.find(L => L.level === 1); + own.forEach((p, i) => L1.members.push({ memberId: p.memberId, name: 'You · position ' + (i + 2), own: true, + email: null, joined: p.created, earnedWei: earnedBy[p.memberId] || '0' })); + } return json(res, 200, { levels: out, counts: out.map(L => L.members.length) }); } // -- broadcast a message to your downline (1/day), on-site inbox + email @@ -1386,7 +1445,10 @@ const server = http.createServer(async (req, res) => { if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const memberId = await auth.refreshMemberId(s); const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; - out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0; + const pool = await ads.pooledCredits((await myMemberIds(s)).ids); + out.purchasedCredits = pool.total; + out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position + out.positionCount = pool.per.length; out.earnedCredits = await ads.earnedBalance(s.email); out.availableCredits = out.purchasedCredits + out.earnedCredits; return json(res, 200, out); @@ -1400,7 +1462,15 @@ const server = http.createServer(async (req, res) => { const fc = await frameCheck(b.targetUrl); if (!fc.ok) return json(res, 400, { error: fc.reason }); } - const r = await ads.createCampaign(s.email, memberId, b); + // charge the best-funded of the member's positions (main + Qualified Start + // wallets). A campaign burns from one member id, so the budget must fit inside it. + const pool = await ads.pooledCredits((await myMemberIds(s)).ids); + const fundId = pool.best.memberId || memberId; + const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0; + const budget = Math.floor(Number(b.budget) || 0); + if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow) + return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' }); + const r = await ads.createCampaign(s.email, fundId, b); return json(res, r.error ? 400 : 200, r); } m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);