[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6FXGQSA7PYXFS7H4HFP5APF":3,"skill-same:01M6FXGQSA7PYXFS7H4HFP5APF":67},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":30,"publishedAt":41,"linkOnly":42,"markdownContent":43,"markdownBody":44,"frontMatter":45,"refName":46,"commitSha":47,"skillDirectory":48,"forksCount":49,"repositoryHtmlUrl":50,"repositoryHomepageUrl":6,"ownerHtmlUrl":51,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":52,"install":53,"resources":66},"01M6FXGQSA7PYXFS7H4HFP5APF","jb-omnichain-ui",null,"Build omnichain UIs for Juicebox projects. Deploy to multiple chains with single payment, display unified cross-chain data.","kbarbel640-del\u002Fskills","kbarbel640-del","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F255315490?v=4","01M6CJPPQGCCESXMG3K7BBGQ5J","01M6CJPPX2PB2BCZERFVM2C2B3",1,"MIT","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fmejango\u002Fjuicy\u002Fjb-omnichain-ui\u002FSKILL.md",[17,22,27],{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000011","运维与部署","SCENARIO","使用场景",{"categoryNo":23,"categoryName":24,"taxonomyType":25,"taxonomyTypeDesc":26},"01JBSEEDCAT000000000000032","设计师","PROFESSION","适用职业",{"categoryNo":28,"categoryName":29,"taxonomyType":25,"taxonomyTypeDesc":26},"01JBSEEDCAT000000000000033","数据分析师",[31,32,33,34,35,36,37,38,39,40],"ui","jb omnichain ui","deploy","data","projects","cross-chain","build","payment","chain","with","2026-11-10 10:12:30",false,"---\nname: jb-omnichain-ui\ndescription: Build omnichain UIs for Juicebox projects. Deploy to multiple chains with single payment, display unified cross-chain data.\n---\n\n# Juicebox V5 Omnichain UI Development\n\nBuild frontends that deploy and interact with Juicebox projects across multiple chains using viem and shared styles.\n\n## Philosophy\n\n> **Pay once on any chain. Deploy everywhere. Query unified data.**\n\n### What is an Omnichain Project?\n\nAn \"omnichain project\" is a set of Juicebox projects deployed across multiple chains, connected via Suckers for token bridging.\n\n**Key concept:** Project IDs cannot be coordinated across chains—each chain assigns the next available ID independently. Deploying to Ethereum might give you project #42, while Optimism gives you project #17. Suckers link these separate projects together so they function as one logical project with unified token bridging.\n\nOmnichain UIs enable:\n- Single-payment multi-chain deployments via Relayr\n- Unified project data across all chains via Bendystraw\n- Cross-chain token bridging visibility via Sucker Groups\n\n## Tool References\n\nFor complete API documentation, see:\n- `\u002Fjb-relayr` - Multi-chain transaction bundling API\n- `\u002Fjb-bendystraw` - Cross-chain data aggregation API\n\n---\n\n## Quick Start\n\n### Relayr (Transactions)\n\n```javascript\nconst RELAYR_API = 'https:\u002F\u002Fapi.relayr.ba5ed.com';\n\n\u002F\u002F 1. Sign forward requests for each chain\n\u002F\u002F 2. POST \u002Fv1\u002Fbundle\u002Fprepaid to get payment options\n\u002F\u002F 3. User pays on one chain\n\u002F\u002F 4. Poll \u002Fv1\u002Fbundle\u002F{uuid} for completion\n\n\u002F\u002F No API key required\n```\n\n### Bendystraw (Data)\n\n```javascript\nconst BENDYSTRAW_API = 'https:\u002F\u002Fbendystraw.xyz\u002F{API_KEY}\u002Fgraphql';\n\n\u002F\u002F API key required - use server-side proxy\n\u002F\u002F Contact @peripheralist on X for key\n```\n\n---\n\n## Omnichain Deploy UI Template\n\nComplete HTML template for deploying projects to multiple chains.\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  \u003Ctitle>Deploy Omnichain Project\u003C\u002Ftitle>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fshared\u002Fstyles.css\">\n  \u003Cstyle>\n    body { max-width: 640px; margin: 0 auto; }\n    .subtitle { color: var(--text-muted); margin-bottom: 1.5rem; }\n    .chain-select { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }\n    .chain-chip { padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 0.875rem; background: var(--bg-secondary); }\n    .chain-chip:hover { border-color: var(--jb-yellow); }\n    .chain-chip.selected { background: var(--accent); border-color: var(--accent); }\n    .chain-chip.payment { background: var(--success); border-color: var(--success); }\n    h2 { font-size: 1rem; color: var(--text-muted); margin-bottom: 1rem; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1>Deploy Omnichain Project\u003C\u002Fh1>\n  \u003Cp class=\"subtitle\">Deploy to multiple chains with a single payment\u003C\u002Fp>\n\n  \u003Cdiv class=\"card\">\n    \u003Cbutton id=\"connect-btn\" class=\"btn\" onclick=\"connectWallet()\">Connect Wallet\u003C\u002Fbutton>\n    \u003Cdiv id=\"wallet-status\" class=\"hidden\">\n      Connected: \u003Cspan id=\"wallet-address\">\u003C\u002Fspan>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Ch2>Select Target Chains\u003C\u002Fh2>\n    \u003Cdiv class=\"chain-select\" id=\"target-chains\">\n      \u003Cdiv class=\"chain-chip\" data-chain=\"1\" onclick=\"toggleChain(this)\">Ethereum\u003C\u002Fdiv>\n      \u003Cdiv class=\"chain-chip\" data-chain=\"10\" onclick=\"toggleChain(this)\">Optimism\u003C\u002Fdiv>\n      \u003Cdiv class=\"chain-chip\" data-chain=\"8453\" onclick=\"toggleChain(this)\">Base\u003C\u002Fdiv>\n      \u003Cdiv class=\"chain-chip\" data-chain=\"42161\" onclick=\"toggleChain(this)\">Arbitrum\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Clabel>Project Name\u003C\u002Flabel>\n    \u003Cinput type=\"text\" id=\"project-name\" placeholder=\"My Omnichain Project\">\n    \u003Clabel>Token Symbol\u003C\u002Flabel>\n    \u003Cinput type=\"text\" id=\"token-symbol\" placeholder=\"OMNI\">\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card hidden\" id=\"payment-section\">\n    \u003Ch2>Select Payment Chain\u003C\u002Fh2>\n    \u003Cp style=\"font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;\">Pay gas on one chain. Relayr handles the rest.\u003C\u002Fp>\n    \u003Cdiv class=\"chain-select\" id=\"payment-chains\">\u003C\u002Fdiv>\n    \u003Cdiv style=\"background: var(--bg-primary); padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.875rem;\">\n      \u003Cdiv class=\"stat-row\">\u003Cspan class=\"stat-label\">Total Gas Cost\u003C\u002Fspan>\u003Cspan class=\"stat-value\" id=\"total-cost\">-\u003C\u002Fspan>\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Cbutton id=\"deploy-btn\" class=\"btn\" onclick=\"startDeploy()\" disabled>Step 1: Sign for Each Chain\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card hidden\" id=\"tx-status\">\n    \u003Ch2>Deployment Status\u003C\u002Fh2>\n    \u003Cdiv id=\"chain-statuses\">\u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cscript type=\"module\">\n    import { createWalletClient, custom, formatEther, encodeFunctionData } from 'https:\u002F\u002Fesm.sh\u002Fviem';\n    import { mainnet, optimism, base, arbitrum } from 'https:\u002F\u002Fesm.sh\u002Fviem\u002Fchains';\n    import { CHAIN_CONFIGS, truncateAddress } from '\u002Fshared\u002Fwallet-utils.js';\n\n    const RELAYR_API = 'https:\u002F\u002Fapi.relayr.ba5ed.com';\n\n    const CHAINS = {\n      1: { name: 'Ethereum', chain: mainnet, explorer: 'https:\u002F\u002Fetherscan.io' },\n      10: { name: 'Optimism', chain: optimism, explorer: 'https:\u002F\u002Foptimistic.etherscan.io' },\n      8453: { name: 'Base', chain: base, explorer: 'https:\u002F\u002Fbasescan.org' },\n      42161: { name: 'Arbitrum', chain: arbitrum, explorer: 'https:\u002F\u002Farbiscan.io' }\n    };\n\n    let walletClient, address;\n    let selectedChains = new Set();\n    let currentQuote = null;\n\n    window.toggleChain = function(el) {\n      const chainId = el.dataset.chain;\n      if (selectedChains.has(chainId)) {\n        selectedChains.delete(chainId);\n        el.classList.remove('selected');\n      } else {\n        selectedChains.add(chainId);\n        el.classList.add('selected');\n      }\n      document.getElementById('deploy-btn').disabled = selectedChains.size === 0;\n    };\n\n    window.connectWallet = async function() {\n      if (!window.ethereum) { alert('Please install MetaMask'); return; }\n      walletClient = createWalletClient({ chain: mainnet, transport: custom(window.ethereum) });\n      const [addr] = await walletClient.requestAddresses();\n      address = addr;\n\n      document.getElementById('wallet-address').textContent = truncateAddress(address);\n      document.getElementById('wallet-status').classList.remove('hidden');\n      document.getElementById('connect-btn').classList.add('hidden');\n    };\n\n    window.startDeploy = async function() {\n      if (selectedChains.size === 0) return;\n\n      const btn = document.getElementById('deploy-btn');\n      btn.disabled = true;\n      btn.textContent = 'Signing...';\n\n      try {\n        const signedRequests = [];\n\n        for (const chainId of selectedChains) {\n          btn.textContent = `Signing for ${CHAINS[chainId].name}...`;\n\n          const calldata = buildLaunchCalldata();\n          const forwarder = CHAIN_CONFIGS[parseInt(chainId)]?.contracts?.JBForwarder;\n          const controller = CHAIN_CONFIGS[parseInt(chainId)]?.contracts?.JBController;\n\n          const signed = await signForwardRequest(parseInt(chainId), calldata, forwarder, controller);\n\n          signedRequests.push({\n            chain: parseInt(chainId),\n            target: forwarder,\n            data: signed.encodedData,\n            value: '0'\n          });\n        }\n\n        btn.textContent = 'Getting quote...';\n        currentQuote = await getRelayrQuote(signedRequests);\n\n        showPaymentOptions(currentQuote);\n        btn.textContent = 'Step 2: Select Payment Chain';\n\n      } catch (error) {\n        console.error(error);\n        btn.textContent = 'Error - Try Again';\n        btn.disabled = false;\n      }\n    };\n\n    async function signForwardRequest(chainId, calldata, forwarder, controller) {\n      const domain = {\n        name: 'Juicebox',\n        version: '1',\n        chainId: chainId,\n        verifyingContract: forwarder\n      };\n\n      const types = {\n        ForwardRequest: [\n          { name: 'from', type: 'address' },\n          { name: 'to', type: 'address' },\n          { name: 'value', type: 'uint256' },\n          { name: 'gas', type: 'uint256' },\n          { name: 'nonce', type: 'uint256' },\n          { name: 'deadline', type: 'uint48' },\n          { name: 'data', type: 'bytes' }\n        ]\n      };\n\n      const deadline = Math.floor(Date.now() \u002F 1000) + 48 * 60 * 60;\n\n      const message = {\n        from: address,\n        to: controller,\n        value: '0',\n        gas: '1000000',\n        nonce: 0,\n        deadline: deadline,\n        data: calldata\n      };\n\n      const signature = await walletClient.signTypedData({\n        account: address,\n        domain,\n        types,\n        primaryType: 'ForwardRequest',\n        message\n      });\n\n      const encodedData = encodeFunctionData({\n        abi: [{ name: 'execute', type: 'function', inputs: [\n          { name: 'request', type: 'tuple', components: [\n            { name: 'from', type: 'address' }, { name: 'to', type: 'address' },\n            { name: 'value', type: 'uint256' }, { name: 'gas', type: 'uint256' },\n            { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint48' },\n            { name: 'data', type: 'bytes' }\n          ]},\n          { name: 'signature', type: 'bytes' }\n        ]}],\n        functionName: 'execute',\n        args: [[message.from, message.to, message.value, message.gas, message.nonce, message.deadline, message.data], signature]\n      });\n\n      return { message, signature, encodedData };\n    }\n\n    function buildLaunchCalldata() {\n      \u002F\u002F Build launchProjectFor calldata - see \u002Fjb-project for full struct encoding\n      return '0x';\n    }\n\n    async function getRelayrQuote(signedRequests) {\n      const response = await fetch(`${RELAYR_API}\u002Fv1\u002Fbundle\u002Fprepaid`, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ transactions: signedRequests, virtual_nonce_mode: 'Disabled' })\n      });\n      if (!response.ok) throw new Error('Failed to get quote');\n      return await response.json();\n    }\n\n    function showPaymentOptions(quote) {\n      document.getElementById('payment-section').classList.remove('hidden');\n\n      const container = document.getElementById('payment-chains');\n      container.innerHTML = '';\n\n      quote.payment_info.forEach(payment => {\n        const chain = CHAINS[payment.chain];\n        if (!chain) return;\n\n        const costEth = formatEther(BigInt(payment.amount));\n        const chip = document.createElement('div');\n        chip.className = 'chain-chip';\n        chip.innerHTML = `${chain.name}\u003Cbr>\u003Csmall>${parseFloat(costEth).toFixed(4)} ETH\u003C\u002Fsmall>`;\n        chip.onclick = () => selectPaymentChain(payment, chip);\n        container.appendChild(chip);\n      });\n    }\n\n    async function selectPaymentChain(payment, chip) {\n      document.querySelectorAll('#payment-chains .chain-chip').forEach(c => c.classList.remove('payment'));\n      chip.classList.add('payment');\n\n      const btn = document.getElementById('deploy-btn');\n      btn.textContent = `Pay on ${CHAINS[payment.chain].name}`;\n      btn.disabled = false;\n      btn.onclick = () => executePayment(payment);\n    }\n\n    async function executePayment(payment) {\n      const btn = document.getElementById('deploy-btn');\n      btn.disabled = true;\n      btn.textContent = 'Confirm in wallet...';\n\n      try {\n        const hash = await walletClient.sendTransaction({\n          account: address,\n          to: payment.target,\n          value: BigInt(payment.amount),\n          data: payment.calldata,\n          chain: CHAINS[payment.chain].chain\n        });\n\n        btn.textContent = 'Payment sent...';\n        showStatusPanel();\n        pollBundleStatus(currentQuote.bundle_uuid);\n\n      } catch (error) {\n        console.error(error);\n        btn.textContent = 'Error - Try Again';\n        btn.disabled = false;\n      }\n    }\n\n    function showStatusPanel() {\n      document.getElementById('tx-status').classList.remove('hidden');\n      const container = document.getElementById('chain-statuses');\n      container.innerHTML = '';\n\n      for (const chainId of selectedChains) {\n        const item = document.createElement('div');\n        item.className = 'stat-row';\n        item.id = `status-${chainId}`;\n        item.innerHTML = `\u003Cspan class=\"stat-label\">${CHAINS[chainId].name}\u003C\u002Fspan>\u003Cspan class=\"stat-value status-badge\">Pending\u003C\u002Fspan>`;\n        container.appendChild(item);\n      }\n    }\n\n    async function pollBundleStatus(bundleUuid) {\n      const poll = async () => {\n        try {\n          const response = await fetch(`${RELAYR_API}\u002Fv1\u002Fbundle\u002F${bundleUuid}`);\n          const status = await response.json();\n\n          status.transactions.forEach((tx, i) => {\n            const chainId = Array.from(selectedChains)[i];\n            const statusEl = document.querySelector(`#status-${chainId} .status-badge`);\n            if (!statusEl) return;\n\n            if (tx.status === 'Success' || tx.status === 'Completed') {\n              statusEl.textContent = 'Complete';\n              statusEl.style.color = 'var(--success)';\n            } else if (tx.status === 'Failed') {\n              statusEl.textContent = 'Failed';\n              statusEl.style.color = 'var(--error)';\n            } else {\n              statusEl.textContent = tx.status;\n            }\n          });\n\n          const allDone = status.transactions.every(tx => ['Success', 'Completed', 'Failed'].includes(tx.status));\n          if (!allDone) setTimeout(poll, 2000);\n          else document.getElementById('deploy-btn').textContent = 'Deployment Complete!';\n\n        } catch (error) {\n          console.error('Poll error:', error);\n          setTimeout(poll, 2000);\n        }\n      };\n\n      poll();\n    }\n  \u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n```\n\n---\n\n## Omnichain Dashboard UI Template\n\nDisplay unified stats across all chains using Bendystraw.\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  \u003Ctitle>Omnichain Dashboard\u003C\u002Ftitle>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fshared\u002Fstyles.css\">\n  \u003Cstyle>\n    body { max-width: 900px; margin: 0 auto; }\n    .subtitle { color: var(--text-muted); margin-bottom: 2rem; }\n    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }\n    .metric-card { text-align: center; }\n    .metric-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; margin-bottom: 0.5rem; }\n    .metric-value { font-size: 1.5rem; font-weight: 600; }\n    .metric-sub { font-size: 0.875rem; color: var(--text-muted); margin-top: 0.25rem; }\n    h2 { font-size: 1rem; color: var(--text-muted); margin-bottom: 1rem; }\n    .chain-breakdown { display: flex; gap: 1rem; flex-wrap: wrap; }\n    .chain-card { flex: 1; min-width: 200px; background: var(--bg-primary); border-radius: 4px; padding: 1rem; }\n    .chain-name { font-weight: 600; margin-bottom: 0.5rem; }\n    .chain-stat { display: flex; justify-content: space-between; font-size: 0.875rem; padding: 0.25rem 0; }\n    .activity-item { display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--border); }\n    .activity-item:last-child { border-bottom: none; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1 id=\"project-name\">Loading...\u003C\u002Fh1>\n  \u003Cp class=\"subtitle\">Omnichain Project Dashboard\u003C\u002Fp>\n\n  \u003Cdiv class=\"grid\">\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Total Balance\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-balance\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">across all chains\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Total Volume\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-volume\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">all-time\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Token Supply\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-supply\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">total issued\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Contributors\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-contributors\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">unique addresses\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Ch2>Chain Breakdown\u003C\u002Fh2>\n    \u003Cdiv class=\"chain-breakdown\" id=\"chain-breakdown\">\n      \u003Cdiv class=\"loading\">Loading chain data...\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Ch2>Recent Activity\u003C\u002Fh2>\n    \u003Cdiv id=\"activity-feed\">\n      \u003Cdiv class=\"loading\">Loading activity...\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cscript type=\"module\">\n    import { truncateAddress, formatEth, formatNumber } from '\u002Fshared\u002Fwallet-utils.js';\n\n    const SUCKER_GROUP_ID = '0x...'; \u002F\u002F Your sucker group ID\n    const API_PROXY = '\u002Fapi\u002Fbendystraw';\n\n    const CHAINS = {\n      1: { name: 'Ethereum' },\n      10: { name: 'Optimism' },\n      8453: { name: 'Base' },\n      42161: { name: 'Arbitrum' }\n    };\n\n    async function query(graphql, variables = {}) {\n      const response = await fetch(API_PROXY, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ query: graphql, variables })\n      });\n      return (await response.json()).data;\n    }\n\n    async function loadDashboard() {\n      try {\n        const data = await query(`\n          query($id: String!) {\n            suckerGroup(id: $id) {\n              volume volumeUsd balance tokenSupply\n              paymentsCount contributorsCount\n              projects_rel {\n                projectId chainId name balance volume paymentsCount\n              }\n            }\n          }\n        `, { id: SUCKER_GROUP_ID });\n\n        const group = data.suckerGroup;\n\n        document.getElementById('project-name').textContent = group.projects_rel[0]?.name || 'Omnichain Project';\n        document.getElementById('total-balance').textContent = formatEth(group.balance) + ' ETH';\n        document.getElementById('total-volume').textContent = formatEth(group.volume) + ' ETH';\n        document.getElementById('total-supply').textContent = formatNumber(group.tokenSupply);\n        document.getElementById('total-contributors').textContent = formatNumber(group.contributorsCount);\n\n        document.getElementById('chain-breakdown').innerHTML = group.projects_rel.map(project => `\n          \u003Cdiv class=\"chain-card\">\n            \u003Cdiv class=\"chain-name\">${CHAINS[project.chainId]?.name || 'Chain ' + project.chainId}\u003C\u002Fdiv>\n            \u003Cdiv class=\"chain-stat\">\u003Cspan>Balance\u003C\u002Fspan>\u003Cspan>${formatEth(project.balance)} ETH\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"chain-stat\">\u003Cspan>Volume\u003C\u002Fspan>\u003Cspan>${formatEth(project.volume)} ETH\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"chain-stat\">\u003Cspan>Payments\u003C\u002Fspan>\u003Cspan>${formatNumber(project.paymentsCount)}\u003C\u002Fspan>\u003C\u002Fdiv>\n          \u003C\u002Fdiv>\n        `).join('');\n\n        if (group.projects_rel.length > 0) {\n          const first = group.projects_rel[0];\n          await loadActivity(first.projectId, first.chainId);\n        }\n\n      } catch (error) {\n        console.error('Failed to load:', error);\n        document.getElementById('project-name').textContent = 'Error loading';\n      }\n    }\n\n    async function loadActivity(projectId, chainId) {\n      const data = await query(`\n        query($projectId: Int!, $chainId: Int!) {\n          payEvents(\n            where: { projectId: $projectId, chainId: $chainId }\n            orderBy: \"timestamp\"\n            orderDirection: \"desc\"\n            limit: 10\n          ) {\n            items { timestamp from amount memo }\n          }\n        }\n      `, { projectId, chainId });\n\n      const events = data.payEvents.items;\n\n      if (events.length === 0) {\n        document.getElementById('activity-feed').innerHTML = '\u003Cdiv class=\"loading\">No recent activity\u003C\u002Fdiv>';\n        return;\n      }\n\n      document.getElementById('activity-feed').innerHTML = events.map(e => `\n        \u003Cdiv class=\"activity-item\">\n          \u003Cdiv>\n            \u003Cdiv>${truncateAddress(e.from)} paid\u003C\u002Fdiv>\n            \u003Cdiv style=\"font-size: 0.75rem; color: var(--text-muted);\">\n              ${e.memo || 'No memo'} · ${new Date(parseInt(e.timestamp) * 1000).toLocaleDateString()}\n            \u003C\u002Fdiv>\n          \u003C\u002Fdiv>\n          \u003Cdiv style=\"font-weight: 600;\">${formatEth(e.amount)} ETH\u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      `).join('');\n    }\n\n    loadDashboard();\n  \u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n```\n\n---\n\n## Server-Side Proxy\n\nBendystraw requires an API key. Use a server-side proxy to keep it secret.\n\n### Next.js\n\n```typescript\n\u002F\u002F pages\u002Fapi\u002Fbendystraw.ts\nexport default async function handler(req, res) {\n  if (req.method !== 'POST') {\n    return res.status(405).json({ error: 'Method not allowed' });\n  }\n\n  const response = await fetch(\n    `https:\u002F\u002Fbendystraw.xyz\u002F${process.env.BENDYSTRAW_API_KEY}\u002Fgraphql`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\u002Fjson' },\n      body: JSON.stringify(req.body)\n    }\n  );\n\n  res.json(await response.json());\n}\n```\n\n### Express\n\n```javascript\napp.post('\u002Fapi\u002Fbendystraw', async (req, res) => {\n  const response = await fetch(\n    `https:\u002F\u002Fbendystraw.xyz\u002F${process.env.BENDYSTRAW_API_KEY}\u002Fgraphql`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\u002Fjson' },\n      body: JSON.stringify(req.body)\n    }\n  );\n\n  res.json(await response.json());\n});\n```\n\n---\n\n## Common Patterns\n\n### Fetch Project + Check if Omnichain\n\n```javascript\nasync function loadProject(projectId, chainId) {\n  const project = await bendystrawQuery(`\n    query($projectId: Int!, $chainId: Int!) {\n      project(projectId: $projectId, chainId: $chainId) {\n        name balance volume suckerGroupId\n      }\n    }\n  `, { projectId, chainId });\n\n  if (project.suckerGroupId) {\n    const group = await bendystrawQuery(`\n      query($id: String!) {\n        suckerGroup(id: $id) {\n          volume balance tokenSupply\n          projects_rel { chainId balance }\n        }\n      }\n    `, { id: project.suckerGroupId });\n\n    return { project, omnichainStats: group };\n  }\n\n  return { project, omnichainStats: null };\n}\n```\n\n### Poll After Relayr Deploy\n\n```javascript\nasync function deployAndWaitForIndex(walletClient, chains, calldata) {\n  \u002F\u002F Deploy via Relayr\n  const quote = await relayrClient.createBundle(\u002F* ... *\u002F);\n  await payForBundle(quote);\n  await relayrClient.waitForCompletion(quote.bundle_uuid);\n\n  \u002F\u002F Wait for Bendystraw to index (~1 minute)\n  console.log('Waiting for indexer...');\n  await new Promise(r => setTimeout(r, 60000));\n\n  \u002F\u002F Now fetch from Bendystraw\n  const project = await bendystrawQuery(\u002F* ... *\u002F);\n  return project;\n}\n```\n\n---\n\n---\n\n## Important Limitation: Aggregate Payout Limits\n\n**Payout limits in omnichain projects are per-chain, not aggregate.**\n\nIf you set a 10 ETH payout limit on a 4-chain project, you could potentially pay out 40 ETH total (10 ETH × 4 chains), not 10 ETH total.\n\nThis is a fundamental constraint of cross-chain systems - there's no atomic way to enforce aggregate limits across chains.\n\n**See `\u002Fjb-omnichain-payout-limits` for:**\n- Why this limitation exists\n- Practical approaches (monitoring, cron automation, oracles)\n- Tradeoffs for each approach\n- Recommendations by use case\n\n**Quick guidance:**\n- Soft caps → Set per-chain limits that sum to ~80% of goal\n- Need automation → Use cron + Relayr to pause when threshold approached\n- Hard compliance limits → Consider single-chain only, or build oracle infrastructure\n\n---\n\n## Related Skills\n\n- `\u002Fjb-omnichain-payout-limits` - Aggregate limit constraints and solutions\n- `\u002Fjb-suckers` - Core sucker mechanics (prepare\u002FtoRemote\u002Fclaim flow)\n- `\u002Fjb-v5-currency-types` - Currency handling for cross-chain consistency\n- `\u002Fjb-relayr` - Complete Relayr API reference\n- `\u002Fjb-bendystraw` - Complete Bendystraw GraphQL reference\n- `\u002Fjb-deploy-ui` - Single-chain deployment UIs\n- `\u002Fjb-interact-ui` - Project interaction UIs\n","# Juicebox V5 Omnichain UI Development\n\nBuild frontends that deploy and interact with Juicebox projects across multiple chains using viem and shared styles.\n\n## Philosophy\n\n> **Pay once on any chain. Deploy everywhere. Query unified data.**\n\n### What is an Omnichain Project?\n\nAn \"omnichain project\" is a set of Juicebox projects deployed across multiple chains, connected via Suckers for token bridging.\n\n**Key concept:** Project IDs cannot be coordinated across chains—each chain assigns the next available ID independently. Deploying to Ethereum might give you project #42, while Optimism gives you project #17. Suckers link these separate projects together so they function as one logical project with unified token bridging.\n\nOmnichain UIs enable:\n- Single-payment multi-chain deployments via Relayr\n- Unified project data across all chains via Bendystraw\n- Cross-chain token bridging visibility via Sucker Groups\n\n## Tool References\n\nFor complete API documentation, see:\n- `\u002Fjb-relayr` - Multi-chain transaction bundling API\n- `\u002Fjb-bendystraw` - Cross-chain data aggregation API\n\n---\n\n## Quick Start\n\n### Relayr (Transactions)\n\n```javascript\nconst RELAYR_API = 'https:\u002F\u002Fapi.relayr.ba5ed.com';\n\n\u002F\u002F 1. Sign forward requests for each chain\n\u002F\u002F 2. POST \u002Fv1\u002Fbundle\u002Fprepaid to get payment options\n\u002F\u002F 3. User pays on one chain\n\u002F\u002F 4. Poll \u002Fv1\u002Fbundle\u002F{uuid} for completion\n\n\u002F\u002F No API key required\n```\n\n### Bendystraw (Data)\n\n```javascript\nconst BENDYSTRAW_API = 'https:\u002F\u002Fbendystraw.xyz\u002F{API_KEY}\u002Fgraphql';\n\n\u002F\u002F API key required - use server-side proxy\n\u002F\u002F Contact @peripheralist on X for key\n```\n\n---\n\n## Omnichain Deploy UI Template\n\nComplete HTML template for deploying projects to multiple chains.\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  \u003Ctitle>Deploy Omnichain Project\u003C\u002Ftitle>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fshared\u002Fstyles.css\">\n  \u003Cstyle>\n    body { max-width: 640px; margin: 0 auto; }\n    .subtitle { color: var(--text-muted); margin-bottom: 1.5rem; }\n    .chain-select { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }\n    .chain-chip { padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 0.875rem; background: var(--bg-secondary); }\n    .chain-chip:hover { border-color: var(--jb-yellow); }\n    .chain-chip.selected { background: var(--accent); border-color: var(--accent); }\n    .chain-chip.payment { background: var(--success); border-color: var(--success); }\n    h2 { font-size: 1rem; color: var(--text-muted); margin-bottom: 1rem; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1>Deploy Omnichain Project\u003C\u002Fh1>\n  \u003Cp class=\"subtitle\">Deploy to multiple chains with a single payment\u003C\u002Fp>\n\n  \u003Cdiv class=\"card\">\n    \u003Cbutton id=\"connect-btn\" class=\"btn\" onclick=\"connectWallet()\">Connect Wallet\u003C\u002Fbutton>\n    \u003Cdiv id=\"wallet-status\" class=\"hidden\">\n      Connected: \u003Cspan id=\"wallet-address\">\u003C\u002Fspan>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Ch2>Select Target Chains\u003C\u002Fh2>\n    \u003Cdiv class=\"chain-select\" id=\"target-chains\">\n      \u003Cdiv class=\"chain-chip\" data-chain=\"1\" onclick=\"toggleChain(this)\">Ethereum\u003C\u002Fdiv>\n      \u003Cdiv class=\"chain-chip\" data-chain=\"10\" onclick=\"toggleChain(this)\">Optimism\u003C\u002Fdiv>\n      \u003Cdiv class=\"chain-chip\" data-chain=\"8453\" onclick=\"toggleChain(this)\">Base\u003C\u002Fdiv>\n      \u003Cdiv class=\"chain-chip\" data-chain=\"42161\" onclick=\"toggleChain(this)\">Arbitrum\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Clabel>Project Name\u003C\u002Flabel>\n    \u003Cinput type=\"text\" id=\"project-name\" placeholder=\"My Omnichain Project\">\n    \u003Clabel>Token Symbol\u003C\u002Flabel>\n    \u003Cinput type=\"text\" id=\"token-symbol\" placeholder=\"OMNI\">\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card hidden\" id=\"payment-section\">\n    \u003Ch2>Select Payment Chain\u003C\u002Fh2>\n    \u003Cp style=\"font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;\">Pay gas on one chain. Relayr handles the rest.\u003C\u002Fp>\n    \u003Cdiv class=\"chain-select\" id=\"payment-chains\">\u003C\u002Fdiv>\n    \u003Cdiv style=\"background: var(--bg-primary); padding: 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.875rem;\">\n      \u003Cdiv class=\"stat-row\">\u003Cspan class=\"stat-label\">Total Gas Cost\u003C\u002Fspan>\u003Cspan class=\"stat-value\" id=\"total-cost\">-\u003C\u002Fspan>\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Cbutton id=\"deploy-btn\" class=\"btn\" onclick=\"startDeploy()\" disabled>Step 1: Sign for Each Chain\u003C\u002Fbutton>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card hidden\" id=\"tx-status\">\n    \u003Ch2>Deployment Status\u003C\u002Fh2>\n    \u003Cdiv id=\"chain-statuses\">\u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cscript type=\"module\">\n    import { createWalletClient, custom, formatEther, encodeFunctionData } from 'https:\u002F\u002Fesm.sh\u002Fviem';\n    import { mainnet, optimism, base, arbitrum } from 'https:\u002F\u002Fesm.sh\u002Fviem\u002Fchains';\n    import { CHAIN_CONFIGS, truncateAddress } from '\u002Fshared\u002Fwallet-utils.js';\n\n    const RELAYR_API = 'https:\u002F\u002Fapi.relayr.ba5ed.com';\n\n    const CHAINS = {\n      1: { name: 'Ethereum', chain: mainnet, explorer: 'https:\u002F\u002Fetherscan.io' },\n      10: { name: 'Optimism', chain: optimism, explorer: 'https:\u002F\u002Foptimistic.etherscan.io' },\n      8453: { name: 'Base', chain: base, explorer: 'https:\u002F\u002Fbasescan.org' },\n      42161: { name: 'Arbitrum', chain: arbitrum, explorer: 'https:\u002F\u002Farbiscan.io' }\n    };\n\n    let walletClient, address;\n    let selectedChains = new Set();\n    let currentQuote = null;\n\n    window.toggleChain = function(el) {\n      const chainId = el.dataset.chain;\n      if (selectedChains.has(chainId)) {\n        selectedChains.delete(chainId);\n        el.classList.remove('selected');\n      } else {\n        selectedChains.add(chainId);\n        el.classList.add('selected');\n      }\n      document.getElementById('deploy-btn').disabled = selectedChains.size === 0;\n    };\n\n    window.connectWallet = async function() {\n      if (!window.ethereum) { alert('Please install MetaMask'); return; }\n      walletClient = createWalletClient({ chain: mainnet, transport: custom(window.ethereum) });\n      const [addr] = await walletClient.requestAddresses();\n      address = addr;\n\n      document.getElementById('wallet-address').textContent = truncateAddress(address);\n      document.getElementById('wallet-status').classList.remove('hidden');\n      document.getElementById('connect-btn').classList.add('hidden');\n    };\n\n    window.startDeploy = async function() {\n      if (selectedChains.size === 0) return;\n\n      const btn = document.getElementById('deploy-btn');\n      btn.disabled = true;\n      btn.textContent = 'Signing...';\n\n      try {\n        const signedRequests = [];\n\n        for (const chainId of selectedChains) {\n          btn.textContent = `Signing for ${CHAINS[chainId].name}...`;\n\n          const calldata = buildLaunchCalldata();\n          const forwarder = CHAIN_CONFIGS[parseInt(chainId)]?.contracts?.JBForwarder;\n          const controller = CHAIN_CONFIGS[parseInt(chainId)]?.contracts?.JBController;\n\n          const signed = await signForwardRequest(parseInt(chainId), calldata, forwarder, controller);\n\n          signedRequests.push({\n            chain: parseInt(chainId),\n            target: forwarder,\n            data: signed.encodedData,\n            value: '0'\n          });\n        }\n\n        btn.textContent = 'Getting quote...';\n        currentQuote = await getRelayrQuote(signedRequests);\n\n        showPaymentOptions(currentQuote);\n        btn.textContent = 'Step 2: Select Payment Chain';\n\n      } catch (error) {\n        console.error(error);\n        btn.textContent = 'Error - Try Again';\n        btn.disabled = false;\n      }\n    };\n\n    async function signForwardRequest(chainId, calldata, forwarder, controller) {\n      const domain = {\n        name: 'Juicebox',\n        version: '1',\n        chainId: chainId,\n        verifyingContract: forwarder\n      };\n\n      const types = {\n        ForwardRequest: [\n          { name: 'from', type: 'address' },\n          { name: 'to', type: 'address' },\n          { name: 'value', type: 'uint256' },\n          { name: 'gas', type: 'uint256' },\n          { name: 'nonce', type: 'uint256' },\n          { name: 'deadline', type: 'uint48' },\n          { name: 'data', type: 'bytes' }\n        ]\n      };\n\n      const deadline = Math.floor(Date.now() \u002F 1000) + 48 * 60 * 60;\n\n      const message = {\n        from: address,\n        to: controller,\n        value: '0',\n        gas: '1000000',\n        nonce: 0,\n        deadline: deadline,\n        data: calldata\n      };\n\n      const signature = await walletClient.signTypedData({\n        account: address,\n        domain,\n        types,\n        primaryType: 'ForwardRequest',\n        message\n      });\n\n      const encodedData = encodeFunctionData({\n        abi: [{ name: 'execute', type: 'function', inputs: [\n          { name: 'request', type: 'tuple', components: [\n            { name: 'from', type: 'address' }, { name: 'to', type: 'address' },\n            { name: 'value', type: 'uint256' }, { name: 'gas', type: 'uint256' },\n            { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint48' },\n            { name: 'data', type: 'bytes' }\n          ]},\n          { name: 'signature', type: 'bytes' }\n        ]}],\n        functionName: 'execute',\n        args: [[message.from, message.to, message.value, message.gas, message.nonce, message.deadline, message.data], signature]\n      });\n\n      return { message, signature, encodedData };\n    }\n\n    function buildLaunchCalldata() {\n      \u002F\u002F Build launchProjectFor calldata - see \u002Fjb-project for full struct encoding\n      return '0x';\n    }\n\n    async function getRelayrQuote(signedRequests) {\n      const response = await fetch(`${RELAYR_API}\u002Fv1\u002Fbundle\u002Fprepaid`, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ transactions: signedRequests, virtual_nonce_mode: 'Disabled' })\n      });\n      if (!response.ok) throw new Error('Failed to get quote');\n      return await response.json();\n    }\n\n    function showPaymentOptions(quote) {\n      document.getElementById('payment-section').classList.remove('hidden');\n\n      const container = document.getElementById('payment-chains');\n      container.innerHTML = '';\n\n      quote.payment_info.forEach(payment => {\n        const chain = CHAINS[payment.chain];\n        if (!chain) return;\n\n        const costEth = formatEther(BigInt(payment.amount));\n        const chip = document.createElement('div');\n        chip.className = 'chain-chip';\n        chip.innerHTML = `${chain.name}\u003Cbr>\u003Csmall>${parseFloat(costEth).toFixed(4)} ETH\u003C\u002Fsmall>`;\n        chip.onclick = () => selectPaymentChain(payment, chip);\n        container.appendChild(chip);\n      });\n    }\n\n    async function selectPaymentChain(payment, chip) {\n      document.querySelectorAll('#payment-chains .chain-chip').forEach(c => c.classList.remove('payment'));\n      chip.classList.add('payment');\n\n      const btn = document.getElementById('deploy-btn');\n      btn.textContent = `Pay on ${CHAINS[payment.chain].name}`;\n      btn.disabled = false;\n      btn.onclick = () => executePayment(payment);\n    }\n\n    async function executePayment(payment) {\n      const btn = document.getElementById('deploy-btn');\n      btn.disabled = true;\n      btn.textContent = 'Confirm in wallet...';\n\n      try {\n        const hash = await walletClient.sendTransaction({\n          account: address,\n          to: payment.target,\n          value: BigInt(payment.amount),\n          data: payment.calldata,\n          chain: CHAINS[payment.chain].chain\n        });\n\n        btn.textContent = 'Payment sent...';\n        showStatusPanel();\n        pollBundleStatus(currentQuote.bundle_uuid);\n\n      } catch (error) {\n        console.error(error);\n        btn.textContent = 'Error - Try Again';\n        btn.disabled = false;\n      }\n    }\n\n    function showStatusPanel() {\n      document.getElementById('tx-status').classList.remove('hidden');\n      const container = document.getElementById('chain-statuses');\n      container.innerHTML = '';\n\n      for (const chainId of selectedChains) {\n        const item = document.createElement('div');\n        item.className = 'stat-row';\n        item.id = `status-${chainId}`;\n        item.innerHTML = `\u003Cspan class=\"stat-label\">${CHAINS[chainId].name}\u003C\u002Fspan>\u003Cspan class=\"stat-value status-badge\">Pending\u003C\u002Fspan>`;\n        container.appendChild(item);\n      }\n    }\n\n    async function pollBundleStatus(bundleUuid) {\n      const poll = async () => {\n        try {\n          const response = await fetch(`${RELAYR_API}\u002Fv1\u002Fbundle\u002F${bundleUuid}`);\n          const status = await response.json();\n\n          status.transactions.forEach((tx, i) => {\n            const chainId = Array.from(selectedChains)[i];\n            const statusEl = document.querySelector(`#status-${chainId} .status-badge`);\n            if (!statusEl) return;\n\n            if (tx.status === 'Success' || tx.status === 'Completed') {\n              statusEl.textContent = 'Complete';\n              statusEl.style.color = 'var(--success)';\n            } else if (tx.status === 'Failed') {\n              statusEl.textContent = 'Failed';\n              statusEl.style.color = 'var(--error)';\n            } else {\n              statusEl.textContent = tx.status;\n            }\n          });\n\n          const allDone = status.transactions.every(tx => ['Success', 'Completed', 'Failed'].includes(tx.status));\n          if (!allDone) setTimeout(poll, 2000);\n          else document.getElementById('deploy-btn').textContent = 'Deployment Complete!';\n\n        } catch (error) {\n          console.error('Poll error:', error);\n          setTimeout(poll, 2000);\n        }\n      };\n\n      poll();\n    }\n  \u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n```\n\n---\n\n## Omnichain Dashboard UI Template\n\nDisplay unified stats across all chains using Bendystraw.\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  \u003Ctitle>Omnichain Dashboard\u003C\u002Ftitle>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fshared\u002Fstyles.css\">\n  \u003Cstyle>\n    body { max-width: 900px; margin: 0 auto; }\n    .subtitle { color: var(--text-muted); margin-bottom: 2rem; }\n    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }\n    .metric-card { text-align: center; }\n    .metric-label { font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; margin-bottom: 0.5rem; }\n    .metric-value { font-size: 1.5rem; font-weight: 600; }\n    .metric-sub { font-size: 0.875rem; color: var(--text-muted); margin-top: 0.25rem; }\n    h2 { font-size: 1rem; color: var(--text-muted); margin-bottom: 1rem; }\n    .chain-breakdown { display: flex; gap: 1rem; flex-wrap: wrap; }\n    .chain-card { flex: 1; min-width: 200px; background: var(--bg-primary); border-radius: 4px; padding: 1rem; }\n    .chain-name { font-weight: 600; margin-bottom: 0.5rem; }\n    .chain-stat { display: flex; justify-content: space-between; font-size: 0.875rem; padding: 0.25rem 0; }\n    .activity-item { display: flex; justify-content: space-between; padding: 0.75rem 0; border-bottom: 1px solid var(--border); }\n    .activity-item:last-child { border-bottom: none; }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Ch1 id=\"project-name\">Loading...\u003C\u002Fh1>\n  \u003Cp class=\"subtitle\">Omnichain Project Dashboard\u003C\u002Fp>\n\n  \u003Cdiv class=\"grid\">\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Total Balance\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-balance\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">across all chains\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Total Volume\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-volume\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">all-time\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Token Supply\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-supply\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">total issued\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n    \u003Cdiv class=\"card metric-card\">\n      \u003Cdiv class=\"metric-label\">Contributors\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-value\" id=\"total-contributors\">-\u003C\u002Fdiv>\n      \u003Cdiv class=\"metric-sub\">unique addresses\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Ch2>Chain Breakdown\u003C\u002Fh2>\n    \u003Cdiv class=\"chain-breakdown\" id=\"chain-breakdown\">\n      \u003Cdiv class=\"loading\">Loading chain data...\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"card\">\n    \u003Ch2>Recent Activity\u003C\u002Fh2>\n    \u003Cdiv id=\"activity-feed\">\n      \u003Cdiv class=\"loading\">Loading activity...\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cscript type=\"module\">\n    import { truncateAddress, formatEth, formatNumber } from '\u002Fshared\u002Fwallet-utils.js';\n\n    const SUCKER_GROUP_ID = '0x...'; \u002F\u002F Your sucker group ID\n    const API_PROXY = '\u002Fapi\u002Fbendystraw';\n\n    const CHAINS = {\n      1: { name: 'Ethereum' },\n      10: { name: 'Optimism' },\n      8453: { name: 'Base' },\n      42161: { name: 'Arbitrum' }\n    };\n\n    async function query(graphql, variables = {}) {\n      const response = await fetch(API_PROXY, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ query: graphql, variables })\n      });\n      return (await response.json()).data;\n    }\n\n    async function loadDashboard() {\n      try {\n        const data = await query(`\n          query($id: String!) {\n            suckerGroup(id: $id) {\n              volume volumeUsd balance tokenSupply\n              paymentsCount contributorsCount\n              projects_rel {\n                projectId chainId name balance volume paymentsCount\n              }\n            }\n          }\n        `, { id: SUCKER_GROUP_ID });\n\n        const group = data.suckerGroup;\n\n        document.getElementById('project-name').textContent = group.projects_rel[0]?.name || 'Omnichain Project';\n        document.getElementById('total-balance').textContent = formatEth(group.balance) + ' ETH';\n        document.getElementById('total-volume').textContent = formatEth(group.volume) + ' ETH';\n        document.getElementById('total-supply').textContent = formatNumber(group.tokenSupply);\n        document.getElementById('total-contributors').textContent = formatNumber(group.contributorsCount);\n\n        document.getElementById('chain-breakdown').innerHTML = group.projects_rel.map(project => `\n          \u003Cdiv class=\"chain-card\">\n            \u003Cdiv class=\"chain-name\">${CHAINS[project.chainId]?.name || 'Chain ' + project.chainId}\u003C\u002Fdiv>\n            \u003Cdiv class=\"chain-stat\">\u003Cspan>Balance\u003C\u002Fspan>\u003Cspan>${formatEth(project.balance)} ETH\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"chain-stat\">\u003Cspan>Volume\u003C\u002Fspan>\u003Cspan>${formatEth(project.volume)} ETH\u003C\u002Fspan>\u003C\u002Fdiv>\n            \u003Cdiv class=\"chain-stat\">\u003Cspan>Payments\u003C\u002Fspan>\u003Cspan>${formatNumber(project.paymentsCount)}\u003C\u002Fspan>\u003C\u002Fdiv>\n          \u003C\u002Fdiv>\n        `).join('');\n\n        if (group.projects_rel.length > 0) {\n          const first = group.projects_rel[0];\n          await loadActivity(first.projectId, first.chainId);\n        }\n\n      } catch (error) {\n        console.error('Failed to load:', error);\n        document.getElementById('project-name').textContent = 'Error loading';\n      }\n    }\n\n    async function loadActivity(projectId, chainId) {\n      const data = await query(`\n        query($projectId: Int!, $chainId: Int!) {\n          payEvents(\n            where: { projectId: $projectId, chainId: $chainId }\n            orderBy: \"timestamp\"\n            orderDirection: \"desc\"\n            limit: 10\n          ) {\n            items { timestamp from amount memo }\n          }\n        }\n      `, { projectId, chainId });\n\n      const events = data.payEvents.items;\n\n      if (events.length === 0) {\n        document.getElementById('activity-feed').innerHTML = '\u003Cdiv class=\"loading\">No recent activity\u003C\u002Fdiv>';\n        return;\n      }\n\n      document.getElementById('activity-feed').innerHTML = events.map(e => `\n        \u003Cdiv class=\"activity-item\">\n          \u003Cdiv>\n            \u003Cdiv>${truncateAddress(e.from)} paid\u003C\u002Fdiv>\n            \u003Cdiv style=\"font-size: 0.75rem; color: var(--text-muted);\">\n              ${e.memo || 'No memo'} · ${new Date(parseInt(e.timestamp) * 1000).toLocaleDateString()}\n            \u003C\u002Fdiv>\n          \u003C\u002Fdiv>\n          \u003Cdiv style=\"font-weight: 600;\">${formatEth(e.amount)} ETH\u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      `).join('');\n    }\n\n    loadDashboard();\n  \u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n```\n\n---\n\n## Server-Side Proxy\n\nBendystraw requires an API key. Use a server-side proxy to keep it secret.\n\n### Next.js\n\n```typescript\n\u002F\u002F pages\u002Fapi\u002Fbendystraw.ts\nexport default async function handler(req, res) {\n  if (req.method !== 'POST') {\n    return res.status(405).json({ error: 'Method not allowed' });\n  }\n\n  const response = await fetch(\n    `https:\u002F\u002Fbendystraw.xyz\u002F${process.env.BENDYSTRAW_API_KEY}\u002Fgraphql`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\u002Fjson' },\n      body: JSON.stringify(req.body)\n    }\n  );\n\n  res.json(await response.json());\n}\n```\n\n### Express\n\n```javascript\napp.post('\u002Fapi\u002Fbendystraw', async (req, res) => {\n  const response = await fetch(\n    `https:\u002F\u002Fbendystraw.xyz\u002F${process.env.BENDYSTRAW_API_KEY}\u002Fgraphql`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application\u002Fjson' },\n      body: JSON.stringify(req.body)\n    }\n  );\n\n  res.json(await response.json());\n});\n```\n\n---\n\n## Common Patterns\n\n### Fetch Project + Check if Omnichain\n\n```javascript\nasync function loadProject(projectId, chainId) {\n  const project = await bendystrawQuery(`\n    query($projectId: Int!, $chainId: Int!) {\n      project(projectId: $projectId, chainId: $chainId) {\n        name balance volume suckerGroupId\n      }\n    }\n  `, { projectId, chainId });\n\n  if (project.suckerGroupId) {\n    const group = await bendystrawQuery(`\n      query($id: String!) {\n        suckerGroup(id: $id) {\n          volume balance tokenSupply\n          projects_rel { chainId balance }\n        }\n      }\n    `, { id: project.suckerGroupId });\n\n    return { project, omnichainStats: group };\n  }\n\n  return { project, omnichainStats: null };\n}\n```\n\n### Poll After Relayr Deploy\n\n```javascript\nasync function deployAndWaitForIndex(walletClient, chains, calldata) {\n  \u002F\u002F Deploy via Relayr\n  const quote = await relayrClient.createBundle(\u002F* ... *\u002F);\n  await payForBundle(quote);\n  await relayrClient.waitForCompletion(quote.bundle_uuid);\n\n  \u002F\u002F Wait for Bendystraw to index (~1 minute)\n  console.log('Waiting for indexer...');\n  await new Promise(r => setTimeout(r, 60000));\n\n  \u002F\u002F Now fetch from Bendystraw\n  const project = await bendystrawQuery(\u002F* ... *\u002F);\n  return project;\n}\n```\n\n---\n\n---\n\n## Important Limitation: Aggregate Payout Limits\n\n**Payout limits in omnichain projects are per-chain, not aggregate.**\n\nIf you set a 10 ETH payout limit on a 4-chain project, you could potentially pay out 40 ETH total (10 ETH × 4 chains), not 10 ETH total.\n\nThis is a fundamental constraint of cross-chain systems - there's no atomic way to enforce aggregate limits across chains.\n\n**See `\u002Fjb-omnichain-payout-limits` for:**\n- Why this limitation exists\n- Practical approaches (monitoring, cron automation, oracles)\n- Tradeoffs for each approach\n- Recommendations by use case\n\n**Quick guidance:**\n- Soft caps → Set per-chain limits that sum to ~80% of goal\n- Need automation → Use cron + Relayr to pause when threshold approached\n- Hard compliance limits → Consider single-chain only, or build oracle infrastructure\n\n---\n\n## Related Skills\n\n- `\u002Fjb-omnichain-payout-limits` - Aggregate limit constraints and solutions\n- `\u002Fjb-suckers` - Core sucker mechanics (prepare\u002FtoRemote\u002Fclaim flow)\n- `\u002Fjb-v5-currency-types` - Currency handling for cross-chain consistency\n- `\u002Fjb-relayr` - Complete Relayr API reference\n- `\u002Fjb-bendystraw` - Complete Bendystraw GraphQL reference\n- `\u002Fjb-deploy-ui` - Single-chain deployment UIs\n- `\u002Fjb-interact-ui` - Project interaction UIs\n",{"name":5,"description":7},"main","e7e336ac628c3e3bd771aff1aaa469c93778f94a","skills\u002Fmejango\u002Fjuicy\u002Fjb-omnichain-ui",0,"https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del","2026-08-23 22:39:37",{"source":8,"skillName":5,"commands":54,"prompt":64,"zipAvailable":65},[55,58,61],{"tool":56,"command":57},"npx","npx skills add kbarbel640-del\u002Fskills --skill jb-omnichain-ui",{"tool":59,"command":60},"bunx","bunx skills add kbarbel640-del\u002Fskills --skill jb-omnichain-ui",{"tool":62,"command":63},"pnpm","pnpm dlx skills add kbarbel640-del\u002Fskills --skill jb-omnichain-ui","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fmejango\u002Fjuicy\u002Fjb-omnichain-ui\u002FSKILL.md",true,[],[68,96,123,144,150,170],{"skillNo":69,"name":70,"nameZh":6,"description":71,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":72,"categories":73,"keywords":77,"publishedAt":41},"01M6G8J55967D6281ACMKAKTEQ","crowd-prompting","A marketplace where AI agents improve prompts, system instructions, tool descriptions, and other text-based content with domain expertise from real-world operations — and earn tokens for valuable contributions.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzzadrian\u002Fcrowd-prompting\u002FSKILL.md",[74],{"categoryNo":75,"categoryName":76,"taxonomyType":25,"taxonomyTypeDesc":26},"01JBSEEDCAT000000000000036","内容创作者",[78,79,80,40,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"crowd prompting","prompting","api","tokens","domain","world","prompts","operations","instructions","text","tool","system","improve","productivity","marketplace","https","content","ai agents",{"skillNo":97,"name":98,"nameZh":6,"description":99,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":100,"categories":101,"keywords":105,"publishedAt":41},"01M6G8J0PKPAYVET6BWE8W4C26","dilemm-ai","Compete in DilemmAI, the prisoner's dilemma AI arena at dilemm.ai. Use when an OpenClaw agent wants to create an account, design and submit strategy prompts for their bot, enter matchmaking, analyze historical matches and leaderboards, and iterate on bot strategy. Requires headless browser automation (no API available yet).","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzyori\u002Fdilemm-ai\u002FSKILL.md",[102],{"categoryNo":103,"categoryName":104,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000023","Skill与智能体开发",[106,80,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,84,122],"dilemm ai","arena","analyze","browser","openclaw agent","create","automation","browser automation","bot","openclaw","design","strategy","headless","compete","submit","matchmaking","use",{"skillNo":124,"name":125,"nameZh":6,"description":126,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":127,"categories":128,"keywords":132,"publishedAt":41},"01M6G8HWAF3K9SW7NP3Z83CPJ6","arxiv-paper-reviews","Interact with arXiv Crawler API to fetch papers, read reviews, and submit comments. Use when working with arXiv papers, fetching paper lists by date\u002Fcategory\u002Finterest, viewing paper details with comments, or submitting paper reviews via API at http:\u002F\u002F150.158.152.82:8000.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzxrys\u002Fweak-accept\u002FSKILL.md",[129],{"categoryNo":130,"categoryName":131,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000021","资料检索与研究",[133,134,135,136,137,138,139,40,140,120,122,141,80,142,143],"arxiv paper reviews","reviews","arxiv","paper","crawler","papers","comments","fetch","read","http","via",{"skillNo":145,"name":125,"nameZh":6,"description":126,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":146,"categories":147,"keywords":149,"publishedAt":41},"01M6G8HQJ7N7WMY6YV7D7SSJ16","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzxrys\u002Farxiv-paper-reviews\u002FSKILL.md",[148],{"categoryNo":130,"categoryName":131,"taxonomyType":20,"taxonomyTypeDesc":21},[136,135,134,133,143,138,139,40,137,120,122,140,141,80,142],{"skillNo":151,"name":152,"nameZh":6,"description":153,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":154,"categories":155,"keywords":156,"publishedAt":41},"01M6G8HM95Y64JNVFC2HN4E2BG","git-summary","Get a quick summary of the current Git repository including status, recent commits, branches, and contributors.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzweack\u002Fgit-summary\u002FSKILL.md",[],[157,158,159,160,161,162,163,164,165,166,167,168,169],"git","summary","git summary","commits","repository","win32","status","linux","contributors","true","quick","get","branches",{"skillNo":171,"name":172,"nameZh":6,"description":173,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":174,"categories":175,"keywords":180,"publishedAt":41},"01M6G8HGEGF05CNEJCGVYFSC4R","agent-constitution","Interact with AgentConstitution governance contracts on Base Sepolia. Check compliance, read rules, log actions, query governance state.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fztsalexey\u002Fagent-constitution\u002FSKILL.md",[176,177],{"categoryNo":103,"categoryName":104,"taxonomyType":20,"taxonomyTypeDesc":21},{"categoryNo":178,"categoryName":179,"taxonomyType":25,"taxonomyTypeDesc":26},"01JBSEEDCAT000000000000037","律师与法务",[181,182,183,184,40,185,186,46,187,141,188,189,35,190,191,93,192,193,194],"agent constitution","constitution","contracts","tree","query","base","log","check","Rules","compliance","actions","state","governance","github"]