// End-to-end test for the marketplace single-product MPP buy flow. // // Fill in the CONFIG block, then run: // cp scripts/test-mpp-buy.ts /tmp/tempo-transfer/ && \ // (cd /tmp/tempo-transfer && ./node_modules/.bin/tsx test-mpp-buy.ts) // // (The script must run inside the isolated /tmp/tempo-transfer/ install — the // repo's @noble/hashes version conflicts with viem's ESM imports here. To // rebuild that dir: mkdir -p /tmp/tempo-transfer && cd /tmp/tempo-transfer \ // && npm init -y && npm install viem@2.47.6 mppx tsx) // // What it does: // 1. Reads the bot wallet's USDC balance + treasury + (optionally) seller. // 2. POSTs to /api/marketplace/buy/:productId via the mppx client. // 3. If the product has a seller with a wallet, the on-chain charge splits // 80/20 — verify both wallets move in the same block. // 4. Follows the first download URL to confirm the JWT-gated download works. // ─── CONFIG ────────────────────────────────────────────────────────────────── const RELAY = 'https://api.bloby.bot'; const PRODUCT_ID = ''; const BOT = { relayToken: '', privateKey: '', wallet: '', }; // Optional: seller wallet to monitor for the 80% split landing. // Leave null if the product has no seller (then 100% goes to treasury). const SELLER_WALLET: string | null = null; const TREASURY = '0x084A80e395FaE8Aaf58F591f47F63DA1EeF06bbC'; // ───────────────────────────────────────────────────────────────────────────── import { Mppx, tempo } from 'mppx/client'; import { privateKeyToAccount } from 'viem/accounts'; import { createPublicClient, http, formatUnits } from 'viem'; import { tempo as tempoChain } from 'viem/chains'; const USDC = '0x20c000000000000000000000b9537d11c60e8b50' as const; const RPC = 'https://rpc.tempo.xyz'; const erc20Abi = [{ name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }], outputs: [{ name: '', type: 'uint256' }], }] as const; function normalizePk(pk: string): `0x${string}` { const t = pk.trim(); return (t.startsWith('0x') ? t : `0x${t}`) as `0x${string}`; } async function balanceOf(client: any, address: `0x${string}`) { return client.readContract({ address: USDC, abi: erc20Abi, functionName: 'balanceOf', args: [address], }); } async function main() { for (const [k, v] of [['PRODUCT_ID', PRODUCT_ID], ['BOT.relayToken', BOT.relayToken], ['BOT.privateKey', BOT.privateKey], ['BOT.wallet', BOT.wallet]]) { if (typeof v !== 'string' || v.startsWith('<')) { console.error(`Fill in ${k} before running.`); process.exit(1); } } const account = privateKeyToAccount(normalizePk(BOT.privateKey)); if (account.address.toLowerCase() !== BOT.wallet.toLowerCase()) { console.error(`privateKey derives ${account.address}, but BOT.wallet is ${BOT.wallet}`); process.exit(1); } const publicClient = createPublicClient({ chain: tempoChain, transport: http(RPC) }); const [botBefore, treasuryBefore, sellerBefore] = await Promise.all([ balanceOf(publicClient, account.address), balanceOf(publicClient, TREASURY as `0x${string}`), SELLER_WALLET ? balanceOf(publicClient, SELLER_WALLET as `0x${string}`) : Promise.resolve(0n), ]); console.log(`Wallet: ${account.address}`); console.log(`Balance before: ${formatUnits(botBefore, 6)} USDC`); console.log(`Treasury before: ${formatUnits(treasuryBefore, 6)} USDC`); if (SELLER_WALLET) console.log(`Seller before: ${formatUnits(sellerBefore, 6)} USDC`); console.log(''); const mppx = Mppx.create({ polyfill: false, methods: [tempo({ account })], }); const url = `${RELAY}/api/marketplace/buy/${PRODUCT_ID}`; console.log(`POST ${url}`); const res = await mppx.fetch(url, { method: 'POST', headers: { 'X-Bloby-Token': BOT.relayToken, 'Content-Type': 'application/json', }, body: JSON.stringify({}), }); const body = await res.text(); const receipt = res.headers.get('Payment-Receipt'); console.log(`Status: ${res.status}`); console.log(`Body: ${body}`); console.log(`Payment-Receipt: ${receipt ?? '(none)'}`); console.log(''); if (!res.ok) { process.exit(1); } const parsed = JSON.parse(body); if (parsed.skills?.length) { console.log(`Bought ${parsed.skills.length} skill(s) via "${parsed.paidVia}":`); for (const skill of parsed.skills) { console.log(` - ${skill.name}@${skill.version} → ${skill.url}`); } console.log(''); // Verify the first download URL actually returns a tarball const dlUrl = parsed.skills[0].url; console.log(`Probing download: HEAD ${dlUrl}`); const dl = await fetch(dlUrl, { method: 'GET' }); const contentLength = dl.headers.get('Content-Length'); console.log(` status: ${dl.status} content-type: ${dl.headers.get('Content-Type')} size: ${contentLength ?? '?'} bytes`); // drain body to release connection if (dl.body) await dl.arrayBuffer(); console.log(''); } const [botAfter, treasuryAfter, sellerAfter] = await Promise.all([ balanceOf(publicClient, account.address), balanceOf(publicClient, TREASURY as `0x${string}`), SELLER_WALLET ? balanceOf(publicClient, SELLER_WALLET as `0x${string}`) : Promise.resolve(0n), ]); console.log(`Balance after: ${formatUnits(botAfter, 6)} USDC (Δ ${formatUnits(botAfter - botBefore, 6)})`); console.log(`Treasury after: ${formatUnits(treasuryAfter, 6)} USDC (Δ +${formatUnits(treasuryAfter - treasuryBefore, 6)})`); if (SELLER_WALLET) console.log(`Seller after: ${formatUnits(sellerAfter, 6)} USDC (Δ +${formatUnits(sellerAfter - sellerBefore, 6)})`); } main().catch((err) => { console.error(err); process.exit(1); });