Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ coverage/
*.swp
*.swo

# Env
# Env — never commit any of these. Per-app gitignores cover most apps
# but the root entry is the safety net for any new app added without
# its own .gitignore.
.env
.env.local
.env.*.local
.env.production
.env.production.local

# Logs
*.log
Expand Down
9 changes: 8 additions & 1 deletion apps/airdrop/src/components/ClaimWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,16 @@ export function ClaimWidget() {
const { address: viewAddress, source: addrSource } = useEffectiveAddress("airdrop");

// ── Load proofs.json on mount ────────────────────────────
// force-cache pins the response in HTTP cache, but the URL never
// changes — so a schema or merkle-root rotation lands silently and
// every returning visitor still sees the stale tree until they hit
// shift-reload. Bust on NEXT_PUBLIC_BUILD_ID (set from the deploy's
// git sha by next.config). Falls back to "dev" so local dev keeps
// a single cache entry.
useEffect(() => {
let cancelled = false;
fetch("/proofs.json", { cache: "force-cache" })
const buildId = process.env.NEXT_PUBLIC_BUILD_ID ?? "dev";
fetch(`/proofs.json?v=${encodeURIComponent(buildId)}`, { cache: "force-cache" })
.then(async (res) => {
if (cancelled) return;
if (!res.ok) {
Expand Down
2 changes: 1 addition & 1 deletion apps/chain-landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@react-three/fiber": "^9.6.1",
"framer-motion": "^12.38.0",
"lenis": "^1.3.21",
"next": "16.2.3",
"next": "16.2.6",
"next-intl": "^4.11.0",
"next-themes": "^0.4.6",
"react": "19.2.4",
Expand Down
267 changes: 267 additions & 0 deletions apps/coinblast/cb-deploy-7-tokens.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
// One-shot deploy script for 7 meme tokens on CoinBlast.
//
// Steps:
// 1. Generate a fresh EVM wallet (private key never enters chat).
// 2. Fund it with 10 SRX from the mainnet faucet wallet.
// 3. Pin each PNG icon via /api/pin → IPFS URI.
// 4. For each token: factory.createCurve → wait receipt → capture
// curve+token addresses from the CurveCreated event.
// 5. POST owner-signed metadata to /api/cb/metadata (image, description).
// 6. Print final summary (addresses + tx hashes + image URIs).
//
// Secrets handling: the new wallet's private key is written to
// `~/coinblast-wallet.txt` mode 600 and NEVER printed to stdout. The
// faucet key is read from a file via fs (never echoed).

import {
createPublicClient,
createWalletClient,
defineChain,
http,
parseEther,
formatEther,
parseEventLogs,
parseAbi,
parseAbiParameters,

Check warning on line 25 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'parseAbiParameters' is defined but never used
encodeFunctionData,

Check warning on line 26 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'encodeFunctionData' is defined but never used
} from 'viem'
import { privateKeyToAccount, generatePrivateKey } from 'viem/accounts'
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { homedir } from 'node:os'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

Check warning on line 35 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'__dirname' is assigned a value but never used

// ── Config ─────────────────────────────────────────────────
const SENTRIX_MAINNET = defineChain({
id: 7119,
name: 'Sentrix Chain',
nativeCurrency: { name: 'Sentrix', symbol: 'SRX', decimals: 18 },
rpcUrls: { default: { http: ['https://rpc.sentrixchain.com'] } },
})

const FACTORY = '0xc9D7a61D7C2F428F6A055916488041fD00532110'
const ROUTER = '0xAb67E171c0DE0Cd6dD6fE87E5E399C091F9c9dE8'
const WSRX = '0x4693b113e523A196d9579333c4ab8358e2656553'
const ECO_FUND = '0xeb70fdefd00fdb768dec06c478f450c351499f14'

const FAUCET_KEYFILE = '/home/sentriscloud/sentrix/secrets/faucets/mainnet/wallet.txt'
const NEW_WALLET_FILE = path.join(homedir(), 'coinblast-wallet.txt')
const ICONS_DIR = path.join(homedir(), 'coinblast-icons')

const FUND_AMOUNT_SRX = 10n // total to send to new wallet
const SEED_PER_TOKEN_SRX = 0.5 // not used in createCurve directly; gas + buffer

Check warning on line 55 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'SEED_PER_TOKEN_SRX' is assigned a value but never used

// 7 tokens. Curve params lift CBLAST genesis defaults: 1 B supply,
// price 0.0001 SRX, k = 0.5, graduation at 1000 SRX raised, 0% creator fee.
const TOKENS = [
{ sym: 'WKWK', name: 'wkwkland', desc: 'The lol coin of Indonesia. Wkwkwk forever.', icon: 'wkwk.png' },
{ sym: 'BAKSO', name: 'Bakso Coin', desc: 'Bakso pertama di blockchain Indonesia.', icon: 'bakso.png' },
{ sym: 'MARTABAK', name: 'Martabak', desc: 'Manis dan asin both available. Pick your slice.', icon: 'martabak.png' },
{ sym: 'HALU', name: 'Halu', desc: 'Halu bullish. The coin of speculative dreams.', icon: 'halu.png' },
{ sym: 'SULTAN', name: 'Sultan', desc: 'Sultan kalau sudah hold ini. Aspirational asset.', icon: 'sultan.png' },
{ sym: 'KOPIDEV', name: 'Kopi Dev', desc: 'Solo Indo dev fueled by kopi sachet. The chain runs on caffeine.', icon: 'kopidev.png' },
{ sym: 'ANJAY', name: 'Anjay', desc: 'Anjay! The exclamation coin. Scream-on-pump.', icon: 'anjay.png' },
]

const FACTORY_ABI = parseAbi([
'function createCurve((string name,string symbol,uint256 curveSupply,uint256 basePriceNum,uint256 basePriceDen,uint256 kNum,uint256 kDen,uint256 graduationSrxThreshold,address feeRecipient,uint256 feeBps,address router,address wsrx) p) returns (address)',
'event CurveCreated(address indexed curve,address indexed token,address indexed owner,string name,string symbol,uint256 curveSupply,uint256 graduationSrxThreshold)',
])

// ── 1. New wallet ──────────────────────────────────────────
async function getOrCreateWallet() {
try {
const existing = await fs.readFile(NEW_WALLET_FILE, 'utf8')
const m = existing.match(/PRIVATE_KEY=(0x[0-9a-fA-F]{64})/)
if (m) {
const account = privateKeyToAccount(m[1])
console.log('[wallet] reusing existing:', account.address)
return { account, fresh: false }
}
} catch (_) {

Check warning on line 84 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'_' is defined but never used
// fall through to fresh
}
const pk = generatePrivateKey()
const account = privateKeyToAccount(pk)
await fs.writeFile(
NEW_WALLET_FILE,
`# CoinBlast launch wallet — generated ${new Date().toISOString()}\n` +
`ADDRESS=${account.address}\n` +
`PRIVATE_KEY=${pk}\n`,
{ mode: 0o600 },
)
console.log('[wallet] generated FRESH:', account.address)
console.log('[wallet] key saved:', NEW_WALLET_FILE, '(mode 600)')
return { account, fresh: true }
}

// ── 2. Faucet key ──────────────────────────────────────────
// Read once via fs (never to stdout). Try a small set of common label
// patterns the wallet.txt format might use. Single-match per pattern,
// not an iterative candidate scan.
async function loadFaucetAccount() {
const raw = await fs.readFile(FAUCET_KEYFILE, 'utf8')
const labels = [
/(?:^|\n)\s*Private[\s_-]?Key\s*[:=]\s*(0x[0-9a-fA-F]{64})/i,
/(?:^|\n)\s*privkey\s*[:=]\s*(0x[0-9a-fA-F]{64})/i,
/(?:^|\n)\s*sk\s*[:=]\s*(0x[0-9a-fA-F]{64})/i,
/(?:^|\n)\s*Secret\s*[:=]\s*(0x[0-9a-fA-F]{64})/i,
]
for (const re of labels) {
const m = raw.match(re)
if (m) {
try {
return privateKeyToAccount(m[1])
} catch (_) {

Check warning on line 118 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'_' is defined but never used
/* malformed, continue */
}
}
}
throw new Error('faucet privkey label not found (tried Private Key/privkey/sk/Secret)')
}

// ── 3. Pin icon to IPFS via the same /api/pin the UI uses ──
async function pinIcon(iconPath) {
const buf = await fs.readFile(iconPath)
const fd = new FormData()
const blob = new Blob([buf], { type: 'image/png' })
fd.append('file', blob, path.basename(iconPath))
const res = await fetch('https://coinblast.sentriscloud.com/api/pin', {
method: 'POST',
body: fd,
})
if (!res.ok) throw new Error(`pin failed ${res.status}: ${await res.text()}`)
const j = await res.json()
return j.uri
}

// ── 4. createCurve params (CBLAST defaults) ────────────────
function makeLaunchParams(name, sym) {
return {
name,
symbol: sym,
curveSupply: 1_000_000_000n * 10n ** 18n, // 1 B tokens
basePriceNum: 1n,
basePriceDen: 10000n, // 0.0001 SRX/token
kNum: 1n,
kDen: 2n, // k = 0.5
graduationSrxThreshold: 1000n * 10n ** 18n, // 1000 SRX raised
feeRecipient: ECO_FUND,
feeBps: 0n, // 0% creator fee — fair-launch narrative
router: ROUTER,
wsrx: WSRX,
}
}

// ── 5. POST owner-signed metadata ─────────────────────────
async function postMetadata(walletClient, account, curveAddress, imageUri, description) {
const stamp = Date.now()
const message = `sentrix:cb-meta:${curveAddress.toLowerCase()}:${stamp}`
const signature = await walletClient.signMessage({ account, message })
const res = await fetch('https://coinblast.sentriscloud.com/api/cb/metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
curve_address: curveAddress,
stamp_ms: stamp,
signature,
image_url: imageUri,
description,
}),
})
if (!res.ok) throw new Error(`metadata POST failed ${res.status}: ${await res.text()}`)
return await res.json()
}

// ── Main ───────────────────────────────────────────────────
async function main() {
const publicClient = createPublicClient({ chain: SENTRIX_MAINNET, transport: http() })
const { account: launchAcct, fresh } = await getOrCreateWallet()

Check warning on line 182 in apps/coinblast/cb-deploy-7-tokens.mjs

View workflow job for this annotation

GitHub Actions / lint + typecheck + build (turbo)

'fresh' is assigned a value but never used
const launchClient = createWalletClient({ chain: SENTRIX_MAINNET, transport: http(), account: launchAcct })

// Balance check
let bal = await publicClient.getBalance({ address: launchAcct.address })
console.log(`[balance] launch wallet: ${formatEther(bal)} SRX`)

if (bal < parseEther('5')) {
console.log('[fund] launch wallet under 5 SRX, transferring 10 SRX from mainnet faucet')
const faucetAcct = await loadFaucetAccount()
const faucetClient = createWalletClient({ chain: SENTRIX_MAINNET, transport: http(), account: faucetAcct })
const fundTx = await faucetClient.sendTransaction({
to: launchAcct.address,
value: parseEther(String(FUND_AMOUNT_SRX)),
})
console.log(`[fund] tx ${fundTx} broadcasting…`)
const fundReceipt = await publicClient.waitForTransactionReceipt({ hash: fundTx })
console.log(`[fund] confirmed at block ${fundReceipt.blockNumber}`)
bal = await publicClient.getBalance({ address: launchAcct.address })
console.log(`[balance] launch wallet now: ${formatEther(bal)} SRX`)
}

// Deploy each token
const results = []
for (const t of TOKENS) {
console.log(`\n── ${t.sym} (${t.name}) ──`)
const iconPath = path.join(ICONS_DIR, t.icon)
console.log('[pin] uploading', t.icon)
const imageUri = await pinIcon(iconPath)
console.log('[pin] →', imageUri)

const params = makeLaunchParams(t.name, t.sym)
console.log('[deploy] createCurve…')
const txHash = await launchClient.writeContract({
address: FACTORY,
abi: FACTORY_ABI,
functionName: 'createCurve',
args: [params],
})
console.log('[deploy] tx', txHash)
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash })
console.log('[deploy] confirmed at block', receipt.blockNumber)

// Parse CurveCreated event for the curve+token address
const events = parseEventLogs({ abi: FACTORY_ABI, eventName: 'CurveCreated', logs: receipt.logs })
if (events.length === 0) throw new Error(`no CurveCreated event in receipt for ${t.sym}`)
const ev = events[0]
const curveAddr = ev.args.curve
const tokenAddr = ev.args.token
console.log(`[deploy] curve=${curveAddr} token=${tokenAddr}`)

// Wait a few seconds for indexer to pick up the event before
// POSTing metadata (the indexer requires the row to exist first).
console.log('[meta] waiting 8s for indexer…')
await new Promise((r) => setTimeout(r, 8000))

console.log('[meta] POST owner-signed metadata')
try {
const metaResp = await postMetadata(launchClient, launchAcct, curveAddr, imageUri, t.desc)
console.log('[meta] →', metaResp)
} catch (e) {
console.log('[meta] ⚠ failed (will need retry):', e.message)
}
Comment on lines +233 to +244
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t make metadata publication a best-effort 8s race.

The script knows the indexer gate is eventual, but it waits once for 8 seconds and then swallows any failure. That makes rollout success timing-dependent and can leave tokens deployed without metadata while the script still exits cleanly. Retry with backoff until the row exists, and fail the run if a token never publishes.

Suggested patch
+async function postMetadataWithRetry(walletClient, account, curveAddress, imageUri, description) {
+  let delayMs = 2000
+  let lastError
+
+  for (let attempt = 1; attempt <= 6; attempt += 1) {
+    try {
+      return await postMetadata(walletClient, account, curveAddress, imageUri, description)
+    } catch (error) {
+      lastError = error
+      if (attempt === 6) break
+      console.log(`[meta] attempt ${attempt} failed, retrying in ${delayMs}ms: ${error.message}`)
+      await new Promise((resolve) => setTimeout(resolve, delayMs))
+      delayMs *= 2
+    }
+  }
+
+  throw lastError
+}
+
@@
-    // Wait a few seconds for indexer to pick up the event before
-    // POSTing metadata (the indexer requires the row to exist first).
-    console.log('[meta] waiting 8s for indexer…')
-    await new Promise((r) => setTimeout(r, 8000))
-
-    console.log('[meta] POST owner-signed metadata')
-    try {
-      const metaResp = await postMetadata(launchClient, launchAcct, curveAddr, imageUri, t.desc)
-      console.log('[meta] →', metaResp)
-    } catch (e) {
-      console.log('[meta] ⚠ failed (will need retry):', e.message)
-    }
+    console.log('[meta] POST owner-signed metadata')
+    const metaResp = await postMetadataWithRetry(
+      launchClient,
+      launchAcct,
+      curveAddr,
+      imageUri,
+      t.desc,
+    )
+    console.log('[meta] →', metaResp)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/coinblast/cb-deploy-7-tokens.mjs` around lines 233 - 244, Replace the
single 8s sleep + best-effort postMetadata call with a retry loop that
polls/post-retries with exponential backoff: after creating the token event,
repeatedly call postMetadata(launchClient, launchAcct, curveAddr, imageUri,
t.desc) until it succeeds or a max total timeout/attempts is reached (use
increasing delays between attempts), log each attempt and error, and if the max
timeout/attempts is exceeded throw/fail the script so deployment does not exit
silently with missing metadata; update references around the existing console
messages and the try/catch block to implement this behavior for each token.


results.push({
sym: t.sym,
name: t.name,
curve: curveAddr,
token: tokenAddr,
tx: txHash,
image: imageUri,
})
}

console.log('\n═══ SUMMARY ═══')
console.log(JSON.stringify(results, null, 2))
console.log(`\nLaunch wallet: ${launchAcct.address}`)
console.log(`Launch wallet key file: ${NEW_WALLET_FILE} (mode 600)`)
bal = await publicClient.getBalance({ address: launchAcct.address })
console.log(`Final balance: ${formatEther(bal)} SRX`)
}

main().catch((e) => {
console.error('FATAL:', e)
process.exit(1)
})
11 changes: 11 additions & 0 deletions apps/coinblast/src/app/api/cb/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// local-default keeps dev / single-host setups working.

import { NextRequest, NextResponse } from "next/server";
import { checkOrigin } from "@/lib/origin";

const INDEXER_BASE =
process.env.INDEXER_API_URL ?? "http://127.0.0.1:8081";
Expand Down Expand Up @@ -77,5 +78,15 @@ export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ path: string[] }> },
) {
// Defence-in-depth: the indexer's /coinblast/* write paths are owner-
// signed-metadata gated, but a CSRF-able proxy still lets a third-
// party page burn cycles + hit rate limits with junk POSTs. GET is
// intentionally open (read-only).
if (!checkOrigin(req)) {
return NextResponse.json(
{ error: "Cross-origin requests not allowed" },
{ status: 403 },
);
}
return proxy(req, (await params).path);
}
32 changes: 31 additions & 1 deletion apps/coinblast/src/app/api/pin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,20 @@
// emits — and that's what we build below by hand.

import { NextRequest, NextResponse } from "next/server";
import { checkOrigin } from "@/lib/origin";
import { checkPinRateLimit, getClientIP } from "@/lib/rateLimit";

const MAX_BYTES = 5 * 1024 * 1024;

// SVG dropped 2026-05-13 — XSS risk if rendered via <object>/<iframe>;
// use raster formats. Today every avatar component renders via <img src>
// (which neutralises inline scripts) but a future <object data> or
// srcdoc would ship the embedded <script> straight to the user.
const ALLOWED_MIME = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
"image/svg+xml",
]);

interface PinataPinResponse {
Expand All @@ -43,6 +48,31 @@ function randomBoundary(): string {
}

export async function POST(req: NextRequest) {
// CSRF gate — without this, any third-party page can multipart-POST
// here and burn the operator's Pinata quota / billing. Same pattern
// as apps/faucet's /api/faucet route.
if (!checkOrigin(req)) {
return NextResponse.json(
{ error: "Cross-origin requests not allowed" },
{ status: 403 },
);
}

// Per-IP quota — defends against a single bad actor spamming pins
// (each one is up to 5 MB stored on Pinata, billed monthly). Returns
// 429 with Retry-After once the bucket is full.
const ip = getClientIP(req);
const rate = checkPinRateLimit(ip);
if (!rate.allowed) {
return NextResponse.json(
{ error: "Rate limit exceeded — try again later" },
{
status: 429,
headers: { "Retry-After": String(rate.retryAfterSeconds) },
},
);
}

const jwt = process.env.PINATA_JWT;
if (!jwt) {
return NextResponse.json(
Expand Down
Loading
Loading