Awesome-omni-skill jb-bendystraw

Bendystraw GraphQL API reference for querying Juicebox project data across all chains. Get project stats, payments, token holders, loans, NFT tiers, unified activity feeds, historical snapshots, and cross-chain aggregations.

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/development/jb-bendystraw-demerzels-lab" ~/.claude/skills/diegosouzapw-awesome-omni-skill-jb-bendystraw-d99c4d && rm -rf "$T"
manifest: skills/development/jb-bendystraw-demerzels-lab/SKILL.md
source content

Bendystraw: Cross-Chain Juicebox Data API

Bendystraw is a GraphQL indexer for Juicebox V5 events across all supported chains. It aggregates data and provides unified cross-chain queries for projects, payments, token holders, and NFTs.

API Base URLs

Production: https://bendystraw.xyz/{API_KEY}/graphql
Testnet: https://testnet.bendystraw.xyz/{API_KEY}/graphql
Playground: https://bendystraw.xyz (browser-based GraphQL explorer)

Authentication

API key required. Contact @peripheralist on Twitter/X to get one.

const response = await fetch(`https://bendystraw.xyz/${API_KEY}/graphql`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    query: '...',
    variables: { ... }
  })
});

Important: Never expose API keys in frontend code. Use a server-side proxy.


Supported Chains

ChainChain IDNetwork
Ethereum1Mainnet
Optimism10Mainnet
Base8453Mainnet
Arbitrum42161Mainnet
Sepolia11155111Testnet

GraphQL Schema Reference

Project Entity

type Project {
  # Identifiers
  id: String!                    # "{chainId}-{projectId}-{version}"
  projectId: Int!
  chainId: Int!
  version: Int!                  # Protocol version (4 or 5)

  # Metadata
  handle: String
  name: String
  description: String
  logoUri: String
  infoUri: String
  owner: String!
  deployer: String

  # Financial
  balance: String!               # Current balance (wei)
  volume: String!                # Total received (wei)
  volumeUsd: String              # USD equivalent
  redeemVolume: String!          # Total redeemed (wei)
  redeemVolumeUsd: String

  # Tokens
  tokenSupply: String!           # Total token supply
  token: String                  # ERC20 address if deployed
  tokenSymbol: String

  # Activity counts
  paymentsCount: Int!
  redeemCount: Int!
  contributorsCount: Int!
  nftsMintedCount: Int!

  # Trending (7-day window)
  trendingScore: Float
  trendingVolume: String
  trendingPaymentsCount: Int

  # Omnichain
  suckerGroupId: String          # Linked cross-chain group

  # Timestamps
  createdAt: Int!
  deployedAt: Int
}

SuckerGroup Entity (Omnichain Projects)

type SuckerGroup {
  id: String!                    # Unique group identifier
  projects: [String!]!           # Array of project IDs

  # Aggregated totals across all chains
  volume: String!
  volumeUsd: String
  balance: String!
  tokenSupply: String!
  paymentsCount: Int!
  contributorsCount: Int!

  # Related projects (expanded)
  projects_rel: [Project!]!
}

Participant Entity (Token Holders)

type Participant {
  id: String!                    # "{chainId}-{projectId}-{address}"
  address: String!
  projectId: Int!
  chainId: Int!

  # Balances
  balance: String!               # Total balance (credits + ERC20)
  creditBalance: String!         # Unclaimed credits
  erc20Balance: String!          # Claimed ERC20 tokens

  # Activity
  volume: String!                # Total contributed
  volumeUsd: String
  paymentsCount: Int!
  redeemCount: Int!

  # Timestamps
  lastPaidAt: Int
  firstPaidAt: Int
}

PayEvent Entity

type PayEvent {
  id: String!
  projectId: Int!
  chainId: Int!
  rulesetId: Int!

  # Transaction
  txHash: String!
  timestamp: Int!
  logIndex: Int!
  blockNumber: Int!

  # Payment details
  from: String!                  # Payer address
  beneficiary: String!           # Token recipient
  amount: String!                # Payment amount (wei)
  amountUsd: String
  distributionFromPayAmount: String!

  # Tokens
  newlyIssuedTokenCount: String!
  beneficiaryTokenCount: String!

  # Metadata
  memo: String
  feeFromPayAmount: String
}

CashOutEvent Entity

type CashOutEvent {
  id: String!
  projectId: Int!
  chainId: Int!
  rulesetId: Int!

  # Transaction
  txHash: String!
  timestamp: Int!

  # Redemption details
  holder: String!
  beneficiary: String!
  cashOutCount: String!          # Tokens burned
  reclaimAmount: String!         # ETH received
  reclaimAmountUsd: String
  metadata: String
}

NFT Entity

type NFT {
  id: String!
  tokenId: Int!
  projectId: Int!
  chainId: Int!
  hook: String!                  # 721 hook address

  # Tier
  tierId: Int!
  tierCategory: Int

  # Ownership
  owner: String!
  createdAt: Int!

  # Metadata
  tokenUri: String
}

ActivityEvent Entity (Unified Activity Feed)

A polymorphic event type that provides a unified view of all project activity. Query this instead of individual event types when building activity feeds.

type ActivityEvent {
  id: String!
  chainId: Int!
  projectId: Int!
  suckerGroupId: String
  version: Int!

  # Transaction
  txHash: String!
  timestamp: Int!
  from: String!

  # Event type discriminator
  type: ActivityEventType!       # Determines which embedded event is populated

  # Embedded events (one will be non-null based on type)
  payEvent: PayEvent
  cashOutTokensEvent: CashOutTokensEvent
  mintNftEvent: MintNftEvent
  sendPayoutsEvent: SendPayoutsEvent
  sendPayoutToSplitEvent: SendPayoutToSplitEvent
  borrowLoanEvent: BorrowLoanEvent
  repayLoanEvent: RepayLoanEvent
  liquidateLoanEvent: LiquidateLoanEvent
  deployErc20Event: DeployErc20Event
  burnEvent: BurnEvent
  mintTokensEvent: MintTokensEvent
  projectCreateEvent: ProjectCreateEvent
  addToBalanceEvent: AddToBalanceEvent
  useAllowanceEvent: UseAllowanceEvent
  decorateBannyEvent: DecorateBannyEvent
  # ... and more

  # Relations
  project: Project
  suckerGroup: SuckerGroup
}

enum ActivityEventType {
  payEvent
  cashOutTokensEvent
  mintNftEvent
  sendPayoutsEvent
  sendPayoutToSplitEvent
  borrowLoanEvent
  repayLoanEvent
  liquidateLoanEvent
  reallocateLoanEvent
  deployErc20Event
  burnEvent
  mintTokensEvent
  manualMintTokensEvent
  manualBurnEvent
  autoIssueEvent
  projectCreateEvent
  addToBalanceEvent
  useAllowanceEvent
  sendReservedTokensToSplitEvent
  sendReservedTokensToSplitsEvent
  decorateBannyEvent
}

Loan Entity (RevLoans)

Active loan state from the RevLoans protocol.

type Loan {
  id: BigInt!                    # Loan ID (NFT token ID)
  projectId: Int!
  chainId: Int!
  version: Int!
  createdAt: Int!

  # Loan terms
  borrowAmount: BigInt!          # Amount borrowed (wei)
  collateral: BigInt!            # Collateral locked (project tokens)
  sourceFeeAmount: BigInt!       # Fee amount
  prepaidDuration: Int!          # Prepaid period in seconds
  prepaidFeePercent: Int!        # Fee percent (basis points)

  # Addresses
  beneficiary: String!           # Who receives borrowed funds
  owner: String!                 # Loan NFT owner
  token: String!                 # Collateral token address
  terminal: String!              # Terminal address

  # Metadata
  tokenUri: String               # Loan NFT metadata URI

  # Relations
  project: Project
  participant: Participant
  wallet: Wallet
}

Wallet Entity

Wallet-level aggregation across all project participations.

type Wallet {
  address: String!               # Wallet address

  # Aggregated stats
  volume: BigInt!                # Total volume across all projects
  volumeUsd: BigInt!             # USD equivalent (18 decimals)
  lastPaidTimestamp: Int         # Most recent payment timestamp

  # Relations
  participants: ParticipantPage  # All project participations
  nfts: NFTPage                  # All owned NFTs
}

NFTTier Entity

NFT tier configuration with pricing and supply.

type NFTTier {
  chainId: Int!
  projectId: Int!
  version: Int!
  tierId: Int!

  # Pricing
  price: BigInt!                 # Price in terminal token (wei)

  # Supply
  initialSupply: Int!            # Original supply
  remainingSupply: Int!          # Current available

  # Configuration
  allowOwnerMint: Boolean!       # Owner can mint without payment
  cannotBeRemoved: Boolean!      # Tier is permanent
  transfersPausable: Boolean!    # Transfers can be paused
  votingUnits: BigInt!           # Governance weight per NFT
  category: Int                  # Tier category
  reserveFrequency: Int          # Reserve rate
  reserveBeneficiary: String     # Reserve recipient

  # Metadata
  encodedIpfsUri: String         # IPFS hash (encoded)
  resolvedUri: String            # Full resolved URI
  metadata: JSON                 # Parsed metadata
  svg: String                    # SVG content if available

  createdAt: Int!

  # Relations
  hook: NFTHook
  nfts: NFTPage                  # Minted NFTs in this tier
  project: Project
}

NFTHook Entity

721 hook contract configuration.

type NFTHook {
  chainId: Int!
  projectId: Int!
  version: Int!
  createdAt: Int!

  address: String!               # Hook contract address
  name: String                   # Collection name
  symbol: String                 # Collection symbol

  # Relations
  nfts: NFTPage
  nftTiers: NFTTierPage
  project: Project
}

ProjectMoment Entity (Historical Snapshots)

Point-in-time snapshots of project state. Useful for historical charts and analytics.

type ProjectMoment {
  projectId: Int!
  chainId: Int!
  version: Int!

  # Snapshot point
  block: Int!                    # Block number
  timestamp: Int!                # Unix timestamp

  # State at snapshot
  volume: BigInt!
  volumeUsd: BigInt!
  balance: BigInt!
  trendingScore: BigInt!

  # Relations
  project: Project
}

SuckerGroupMoment Entity

Point-in-time snapshots of cross-chain aggregated state.

type SuckerGroupMoment {
  suckerGroupId: String!

  # Snapshot point
  block: Int!
  timestamp: Int!

  # Aggregated state
  volume: BigInt!
  volumeUsd: BigInt!
  balance: BigInt!
  tokenSupply: BigInt!

  # Relations
  suckerGroup: SuckerGroup
}

SuckerTransaction Entity (Cross-Chain Bridging)

Token bridging transactions between chains via suckers.

type SuckerTransaction {
  index: Int!                    # Transaction index
  projectId: Int!
  chainId: Int!                  # Source chain
  version: Int!
  suckerGroupId: String!
  createdAt: Int!

  # Bridge details
  token: String!                 # Token being bridged
  sucker: String!                # Source sucker address
  peer: String!                  # Destination sucker address
  peerChainId: Int!              # Destination chain
  beneficiary: String!           # Recipient address

  # Amounts
  projectTokenCount: BigInt!     # Project tokens bridged
  terminalTokenAmount: BigInt!   # Terminal tokens (if any)

  # State
  root: String                   # Merkle root
  status: SuckerTransactionStatus!

  # Relations
  suckerGroup: SuckerGroup
}

enum SuckerTransactionStatus {
  pending
  completed
  failed
}

SendPayoutsEvent Entity

Payout distribution events from the terminal.

type SendPayoutsEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  logIndex: Int!

  # Payout details
  caller: String!                # Who triggered payout
  from: String!                  # Source address
  rulesetId: Int!
  rulesetCycleNumber: Int!

  # Amounts
  amount: BigInt!                # Total payout amount
  amountUsd: BigInt!
  amountPaidOut: BigInt!         # Actually distributed
  amountPaidOutUsd: BigInt!
  netLeftoverPayoutAmount: BigInt!  # Remaining after splits
  fee: BigInt!                   # Protocol fee
  feeUsd: BigInt!

  # Relations
  project: Project
}

UseAllowanceEvent Entity

Surplus allowance usage events.

type UseAllowanceEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!

  # Transaction
  txHash: String!
  timestamp: Int!

  # Allowance details
  caller: String!
  beneficiary: String!           # Who receives funds
  rulesetId: Int!
  rulesetCycleNumber: Int!

  # Amounts
  amount: BigInt!                # Amount used
  amountUsd: BigInt!
  netAmount: BigInt!             # After fees
  netAmountUsd: BigInt!

  # Relations
  project: Project
}

PermissionHolder Entity

Operator permissions granted to accounts.

type PermissionHolder {
  chainId: Int!
  projectId: Int!
  version: Int!

  account: String!               # Account with permissions
  operator: String!              # Operator address
  permissions: [Int!]!           # Permission IDs granted
  isRevnetOperator: Boolean!     # Is a revnet operator

  # Relations
  project: Project
}

BorrowLoanEvent Entity

Loan creation events from RevLoans.

type BorrowLoanEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Loan details
  borrowAmount: BigInt!          # Amount borrowed
  collateral: BigInt!            # Collateral locked
  sourceFeeAmount: BigInt!       # Fee paid
  prepaidDuration: Int!          # Prepaid period (seconds)
  prepaidFeePercent: Int!        # Fee percent (basis points)
  beneficiary: String!           # Loan recipient
  token: String!                 # Collateral token
  terminal: String!              # Terminal address

  # Relations
  project: Project
}

RepayLoanEvent Entity

Loan repayment events.

type RepayLoanEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Repayment details
  loanId: BigInt!                # Loan being repaid
  paidOffLoanId: BigInt          # If fully paid off
  repayBorrowAmount: BigInt!     # Amount repaid
  collateralCountToReturn: BigInt!  # Collateral returned

  # Relations
  project: Project
}

LiquidateLoanEvent Entity

Loan liquidation events.

type LiquidateLoanEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Liquidation details
  borrowAmount: BigInt!          # Outstanding borrow
  collateral: BigInt!            # Collateral seized

  # Relations
  project: Project
}

ReallocateLoanEvent Entity

Loan reallocation events (moving collateral between loans).

type ReallocateLoanEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Reallocation details
  loanId: BigInt!                # Source loan
  reallocatedLoanId: BigInt!     # Target loan
  removedCollateralCount: BigInt!  # Collateral moved

  # Relations
  project: Project
}

BurnEvent Entity

Token burn events (from cash outs).

type BurnEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  from: String!

  # Burn details
  amount: BigInt!                # Total burned
  creditAmount: BigInt!          # Credits burned
  erc20Amount: BigInt!           # ERC20 burned

  # Relations
  project: Project
}

MintTokensEvent Entity

Token minting events (from payments).

type MintTokensEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Mint details
  beneficiary: String!           # Token recipient
  beneficiaryTokenCount: BigInt! # Tokens to beneficiary
  reservedPercent: BigInt!       # Reserved rate
  tokenCount: BigInt!            # Total minted
  memo: String                   # Payment memo

  # Relations
  project: Project
}

ManualMintTokensEvent Entity

Manual token minting by project owner.

type ManualMintTokensEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Mint details
  beneficiary: String!
  beneficiaryTokenCount: BigInt!
  reservedPercent: BigInt!
  tokenCount: BigInt!
  memo: String

  # Relations
  project: Project
}

ManualBurnEvent Entity

Manual token burning.

type ManualBurnEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  from: String!

  # Burn details
  amount: BigInt!
  creditAmount: BigInt!
  erc20Amount: BigInt!

  # Relations
  project: Project
}

MintNftEvent Entity

NFT minting events.

type MintNftEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Mint details
  hook: String!                  # 721 hook address
  beneficiary: String!           # NFT recipient
  tierId: Int!                   # Tier minted
  tokenId: BigInt!               # Token ID
  totalAmountPaid: BigInt!       # Amount paid

  # Relations
  project: Project
  tier: NFTTier
  nft: NFT
}

DeployErc20Event Entity

ERC20 token deployment events.

type DeployErc20Event {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Token details
  symbol: String!                # Token symbol
  name: String!                  # Token name
  token: String!                 # Token address

  # Relations
  project: Project
}

ProjectCreateEvent Entity

Project creation events.

type ProjectCreateEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Relations
  project: Project
}

AddToBalanceEvent Entity

Direct balance addition events.

type AddToBalanceEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Details
  amount: BigInt!                # Amount added
  memo: String                   # Memo
  metadata: String               # Additional metadata
  returnedFees: BigInt           # Fees returned

  # Relations
  project: Project
}

SendPayoutToSplitEvent Entity

Individual split payout events.

type SendPayoutToSplitEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Split details
  rulesetId: Int!
  group: BigInt!                 # Split group
  beneficiary: String!           # Split recipient
  splitProjectId: Int            # If split to project
  hook: String                   # Split hook if any

  # Amounts
  amount: BigInt!                # Gross amount
  netAmount: BigInt!             # After fees
  amountUsd: BigInt!
  percent: Int!                  # Split percent
  lockedUntil: BigInt            # Lock timestamp
  preferAddToBalance: Boolean!   # Add to balance vs pay

  # Relations
  project: Project
}

SendReservedTokensToSplitEvent Entity

Reserved token distribution to individual split.

type SendReservedTokensToSplitEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Split details
  rulesetId: Int!
  groupId: BigInt!
  beneficiary: String!
  splitProjectId: Int
  hook: String
  tokenCount: BigInt!            # Tokens sent
  percent: Int!
  lockedUntil: BigInt
  preferAddToBalance: Boolean!

  # Relations
  project: Project
}

SendReservedTokensToSplitsEvent Entity

Batch reserved token distribution event.

type SendReservedTokensToSplitsEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!
  suckerGroupId: String

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Distribution details
  rulesetId: Int!
  rulesetCycleNumber: Int!
  owner: String!                 # Project owner
  tokenCount: BigInt!            # Total distributed
  leftoverAmount: BigInt!        # Remaining after splits

  # Relations
  project: Project
}

AutoIssueEvent Entity

Auto-issuance events (revnet stage transitions).

type AutoIssueEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Issuance details
  stageId: BigInt!               # Revnet stage
  beneficiary: String!           # Token recipient
  count: BigInt!                 # Tokens issued

  # Relations
  project: Project
}

StoreAutoIssuanceAmountEvent Entity

Auto-issuance configuration events.

type StoreAutoIssuanceAmountEvent {
  id: String!
  chainId: Int!
  version: Int!
  projectId: Int!

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Config details
  stageId: BigInt!               # Revnet stage
  beneficiary: String!           # Configured recipient
  count: BigInt!                 # Amount to auto-issue

  # Relations
  project: Project
}

DecorateBannyEvent Entity

Banny NFT decoration events.

type DecorateBannyEvent {
  id: String!
  chainId: Int!
  version: Int!

  # Transaction
  txHash: String!
  timestamp: Int!
  caller: String!
  from: String!
  logIndex: Int!

  # Decoration details
  bannyBodyId: BigInt!           # Banny being decorated
  outfitIds: [BigInt!]!          # Outfit NFT IDs
  backgroundId: BigInt           # Background NFT ID
  tokenUri: String               # Updated token URI
  tokenUriMetadata: JSON         # Parsed metadata

  # Relations
  bannyNft: NFT
}

CashOutTaxSnapshot Entity

Historical cash-out tax rate snapshots.

type CashOutTaxSnapshot {
  chainId: Int!
  projectId: Int!
  suckerGroupId: String
  version: Int!

  # Snapshot period
  start: BigInt!                 # Period start timestamp
  duration: BigInt!              # Period duration
  rulesetId: BigInt!             # Ruleset ID

  # Tax rate
  cashOutTax: Int!               # Tax rate (basis points)
}

ParticipantSnapshot Entity (GraphQL)

Historical participant balance snapshots via GraphQL (alternative to REST endpoint).

type ParticipantSnapshot {
  chainId: Int!
  projectId: Int!
  suckerGroupId: String
  version: Int!

  # Snapshot point
  block: Int!
  timestamp: Int!

  # Participant
  address: String!

  # Balances at snapshot
  balance: BigInt!
  creditBalance: BigInt!
  erc20Balance: BigInt!
  volume: BigInt!
  volumeUsd: BigInt!
}

Critical Concepts

Project Identity

A Juicebox project is uniquely identified by three fields:

projectId + chainId + version
.

This is crucial because:

  • V4 and V5 are completely different protocols. Project #64 on Ethereum V4 is NOT the same project as Project #64 on Ethereum V5.
  • The same projectId can exist on multiple chains (via suckers/omnichain), but those ARE the same project.
  • Always include
    version
    when querying or displaying projects.
// WRONG: Groups V4 and V5 together
const groupKey = `${project.projectId}-${project.chainId}`;

// CORRECT: Keeps V4 and V5 separate
const groupKey = `${project.projectId}-v${project.version}`;

Multi-Chain Grouping

When displaying "top projects" or aggregating stats:

  • Same projectId + version across chains → Group together (same project via suckers)
  • Same projectId, different version → Keep separate (completely different projects)
// Group projects by projectId + version (V4 and V5 are different projects!)
const grouped = new Map();

for (const project of projects) {
  const groupKey = `${project.projectId}-v${project.version || 4}`;
  const existing = grouped.get(groupKey);

  if (existing) {
    // Add chain to existing group
    if (!existing.chainIds.includes(project.chainId)) {
      existing.chainIds.push(project.chainId);
    }
    // Sum volumes
    existing.totalVolumeUsd += parseFloat(project.volumeUsd || '0');
  } else {
    grouped.set(groupKey, {
      ...project,
      chainIds: [project.chainId],
      totalVolumeUsd: parseFloat(project.volumeUsd || '0')
    });
  }
}

USD Value Formatting

The

volumeUsd
,
amountUsd
, and similar fields use 18 decimal format (like wei). You must convert properly:

function formatVolumeUsd(volumeUsd) {
  if (!volumeUsd || volumeUsd === '0') return '$0';

  try {
    // volumeUsd comes in 18 decimal format
    // Use BigInt to avoid precision loss on large numbers
    const raw = BigInt(volumeUsd.split('.')[0]);
    const usd = Number(raw / BigInt(1e12)) / 1e6; // Divide in steps

    if (usd >= 1_000_000) return `$${(usd / 1_000_000).toFixed(1)}M`;
    if (usd >= 1_000) return `$${(usd / 1_000).toFixed(1)}k`;
    if (usd >= 1) return `$${usd.toFixed(0)}`;
    return `$${usd.toFixed(2)}`;
  } catch {
    return '$0';
  }
}

Warning: Do NOT use

parseFloat()
directly on volumeUsd for large values—JavaScript loses precision beyond ~15 digits.

Filtering by Version

When querying projects, filter by version to avoid mixing V4 and V5 data:

# Get only V5 projects
query V5Projects($limit: Int!) {
  projects(
    where: { version: 5 }
    orderBy: "volumeUsd"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      projectId
      chainId
      version
      name
      volumeUsd
    }
  }
}

To display both versions, query them separately and handle grouping in your application.


Query Examples

Get Single Project

query GetProject($projectId: Int!, $chainId: Int!) {
  project(projectId: $projectId, chainId: $chainId) {
    id
    name
    handle
    owner
    balance
    volume
    volumeUsd
    tokenSupply
    paymentsCount
    contributorsCount
    suckerGroupId
  }
}

Get Participant (Token Holder)

query GetParticipant($projectId: Int!, $chainId: Int!, $address: String!) {
  participant(projectId: $projectId, chainId: $chainId, address: $address) {
    balance
    creditBalance
    erc20Balance
    volume
    volumeUsd
    paymentsCount
  }
}

Get Sucker Group (Omnichain Totals)

query GetSuckerGroup($id: String!) {
  suckerGroup(id: $id) {
    id
    volume
    volumeUsd
    balance
    tokenSupply
    paymentsCount
    contributorsCount
    projects_rel {
      projectId
      chainId
      name
      balance
      volume
    }
  }
}

List Projects

query ListProjects($chainId: Int, $version: Int, $limit: Int!, $offset: Int!) {
  projects(
    where: { chainId: $chainId, version: $version }
    orderBy: "volumeUsd"
    orderDirection: "desc"
    limit: $limit
    offset: $offset
  ) {
    items {
      projectId
      chainId
      version
      name
      handle
      volumeUsd
      balance
      paymentsCount
    }
    totalCount
  }
}

List Recent Payments

query ListPayments($projectId: Int!, $chainId: Int!, $limit: Int!) {
  payEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      from
      beneficiary
      amount
      amountUsd
      memo
      newlyIssuedTokenCount
    }
  }
}

List Top Token Holders

query ListParticipants($projectId: Int!, $chainId: Int!, $limit: Int!) {
  participants(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "balance"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      address
      balance
      creditBalance
      erc20Balance
      volume
      paymentsCount
    }
    totalCount
  }
}

Get Trending Projects

query TrendingProjects($limit: Int!) {
  projects(
    orderBy: "trendingScore"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      projectId
      chainId
      name
      handle
      trendingScore
      trendingVolume
      trendingPaymentsCount
    }
  }
}

List Cash Out Events

query ListCashOuts($projectId: Int!, $chainId: Int!, $limit: Int!) {
  cashOutEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      holder
      beneficiary
      cashOutCount
      reclaimAmount
      reclaimAmountUsd
    }
  }
}

List NFTs for Project

query ListNFTs($projectId: Int!, $chainId: Int!, $limit: Int!) {
  nfts(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "createdAt"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      tokenId
      tierId
      tierCategory
      owner
      createdAt
      tokenUri
    }
  }
}

Get Unified Activity Feed

The most powerful query for building activity feeds. Returns all event types in a single query.

query GetActivityFeed($projectId: Int!, $chainId: Int!, $limit: Int!) {
  activityEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      id
      timestamp
      txHash
      from
      type                        # Discriminator for event type

      # Include fields from each possible event type
      payEvent {
        amount
        amountUsd
        beneficiary
        memo
      }
      cashOutTokensEvent {
        cashOutCount
        reclaimAmount
        holder
      }
      mintNftEvent {
        tierId
        tokenId
      }
      sendPayoutsEvent {
        amount
        amountPaidOut
        fee
      }
      borrowLoanEvent {
        borrowAmount
        collateral
      }
    }
  }
}

Get Omnichain Activity Feed

Query activity across all chains for a sucker group.

query GetOmnichainActivity($suckerGroupId: String!, $limit: Int!) {
  activityEvents(
    where: { suckerGroupId: $suckerGroupId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      chainId
      timestamp
      type
      txHash
      from
      payEvent { amount, memo }
      cashOutTokensEvent { cashOutCount, reclaimAmount }
    }
  }
}

List Active Loans

query ListLoans($projectId: Int!, $chainId: Int!, $limit: Int!) {
  loans(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "createdAt"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      id
      borrowAmount
      collateral
      prepaidDuration
      prepaidFeePercent
      owner
      beneficiary
      createdAt
    }
    totalCount
  }
}

Get Loan by ID

query GetLoan($id: BigInt!) {
  loan(id: $id) {
    id
    projectId
    chainId
    borrowAmount
    collateral
    sourceFeeAmount
    prepaidDuration
    prepaidFeePercent
    beneficiary
    owner
    token
    terminal
    tokenUri
    createdAt
  }
}

Get Wallet Portfolio

query GetWallet($address: String!) {
  wallet(address: $address) {
    address
    volume
    volumeUsd
    lastPaidTimestamp
    participants(limit: 100) {
      items {
        projectId
        chainId
        balance
        volume
      }
    }
    nfts(limit: 50) {
      items {
        projectId
        chainId
        tokenId
        tierId
      }
    }
  }
}

List NFT Tiers

query ListNFTTiers($projectId: Int!, $chainId: Int!, $limit: Int!) {
  nftTiers(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "tierId"
    orderDirection: "asc"
    limit: $limit
  ) {
    items {
      tierId
      price
      initialSupply
      remainingSupply
      category
      votingUnits
      resolvedUri
      metadata
      svg
    }
  }
}

Get NFT Hook Details

query GetNFTHook($projectId: Int!, $chainId: Int!) {
  nftHooks(
    where: { projectId: $projectId, chainId: $chainId }
    limit: 1
  ) {
    items {
      address
      name
      symbol
      nftTiers(limit: 100) {
        items {
          tierId
          price
          remainingSupply
        }
      }
    }
  }
}

Get Historical Project Snapshots

query GetProjectHistory($projectId: Int!, $chainId: Int!, $limit: Int!) {
  projectMoments(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      block
      timestamp
      volume
      volumeUsd
      balance
      trendingScore
    }
  }
}

Get Cross-Chain Bridge Transactions

query GetSuckerTransactions($suckerGroupId: String!, $limit: Int!) {
  suckerTransactions(
    where: { suckerGroupId: $suckerGroupId }
    orderBy: "createdAt"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      index
      chainId
      peerChainId
      beneficiary
      projectTokenCount
      terminalTokenAmount
      status
      createdAt
    }
  }
}

List Payout Events

query ListPayouts($projectId: Int!, $chainId: Int!, $limit: Int!) {
  sendPayoutsEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      caller
      rulesetCycleNumber
      amount
      amountPaidOut
      fee
      netLeftoverPayoutAmount
    }
  }
}

List Allowance Usage

query ListAllowanceUsage($projectId: Int!, $chainId: Int!, $limit: Int!) {
  useAllowanceEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      caller
      beneficiary
      amount
      netAmount
      rulesetCycleNumber
    }
  }
}

List Loan Events (Borrow/Repay/Liquidate)

query ListBorrowEvents($projectId: Int!, $chainId: Int!, $limit: Int!) {
  borrowLoanEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      borrowAmount
      collateral
      prepaidDuration
      beneficiary
    }
  }
}

query ListRepayEvents($projectId: Int!, $chainId: Int!, $limit: Int!) {
  repayLoanEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      loanId
      repayBorrowAmount
      collateralCountToReturn
    }
  }
}

query ListLiquidations($projectId: Int!, $chainId: Int!, $limit: Int!) {
  liquidateLoanEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      borrowAmount
      collateral
    }
  }
}

List Token Burns

query ListBurns($projectId: Int!, $chainId: Int!, $limit: Int!) {
  burnEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      from
      amount
      creditAmount
      erc20Amount
    }
  }
}

List NFT Mints

query ListNFTMints($projectId: Int!, $chainId: Int!, $limit: Int!) {
  mintNftEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      beneficiary
      tierId
      tokenId
      totalAmountPaid
    }
  }
}

Get Permission Holders

query ListPermissionHolders($projectId: Int!, $chainId: Int!) {
  permissionHolders(
    where: { projectId: $projectId, chainId: $chainId }
    limit: 100
  ) {
    items {
      account
      operator
      permissions
      isRevnetOperator
    }
  }
}

List Project Creations

query ListProjectCreations($chainId: Int!, $limit: Int!) {
  projectCreateEvents(
    where: { chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      projectId
      caller
      project {
        name
        handle
        owner
      }
    }
  }
}

List ERC20 Deployments

query ListERC20Deployments($chainId: Int!, $limit: Int!) {
  deployErc20Events(
    where: { chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      projectId
      name
      symbol
      token
    }
  }
}

Get Cash Out Tax History

query GetCashOutTaxHistory($projectId: Int!, $chainId: Int!, $limit: Int!) {
  cashOutTaxSnapshots(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "start"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      start
      duration
      rulesetId
      cashOutTax
    }
  }
}

Get Participant History (GraphQL Snapshots)

query GetParticipantHistory(
  $projectId: Int!,
  $chainId: Int!,
  $address: String!,
  $limit: Int!
) {
  participantSnapshots(
    where: { projectId: $projectId, chainId: $chainId, address: $address }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      block
      timestamp
      balance
      creditBalance
      erc20Balance
      volume
      volumeUsd
    }
  }
}

List Reserved Token Distributions

query ListReservedDistributions($projectId: Int!, $chainId: Int!, $limit: Int!) {
  sendReservedTokensToSplitsEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      rulesetCycleNumber
      tokenCount
      leftoverAmount
      owner
    }
  }
}

List Split Payouts

query ListSplitPayouts($projectId: Int!, $chainId: Int!, $limit: Int!) {
  sendPayoutToSplitEvents(
    where: { projectId: $projectId, chainId: $chainId }
    orderBy: "timestamp"
    orderDirection: "desc"
    limit: $limit
  ) {
    items {
      timestamp
      txHash
      beneficiary
      splitProjectId
      amount
      netAmount
      percent
    }
  }
}

Filtering

The

where
clause supports these operators:

where: {
  # Exact match
  projectId: 1
  chainId: 1

  # Multiple values (OR)
  chainId_in: [1, 10, 8453]

  # Comparison operators
  balance_gt: "1000000000000000000"
  balance_gte: "1000000000000000000"
  balance_lt: "100000000000000000000"
  balance_lte: "100000000000000000000"
  timestamp_gte: 1704067200

  # Text search
  name_contains: "dao"
  handle_starts_with: "jb"
}

Sorting

orderBy: "volume"           # Field to sort by
orderDirection: "desc"      # "asc" or "desc"

Sortable Fields by Entity:

EntitySortable Fields
Project
volume
,
balance
,
tokenSupply
,
paymentsCount
,
createdAt
,
trendingScore
Participant
balance
,
volume
,
paymentsCount
PayEvent
timestamp
,
amount
CashOutEvent
timestamp
,
reclaimAmount
NFT
createdAt
,
tokenId
ActivityEvent
timestamp
Loan
createdAt
,
borrowAmount
,
collateral
Wallet
volume
,
volumeUsd
,
lastPaidTimestamp
NFTTier
tierId
,
price
,
createdAt
ProjectMoment
timestamp
,
block
SuckerTransaction
createdAt
SendPayoutsEvent
timestamp
,
amount
UseAllowanceEvent
timestamp
,
amount
BorrowLoanEvent
timestamp
,
borrowAmount
RepayLoanEvent
timestamp
,
repayBorrowAmount
LiquidateLoanEvent
timestamp
BurnEvent
timestamp
,
amount
MintTokensEvent
timestamp
,
tokenCount
MintNftEvent
timestamp
,
tokenId
DeployErc20Event
timestamp
ProjectCreateEvent
timestamp
SendPayoutToSplitEvent
timestamp
,
amount
CashOutTaxSnapshot
start
ParticipantSnapshot
timestamp
,
block

Pagination

All list queries support pagination:

query PaginatedPayments(
  $projectId: Int!,
  $chainId: Int!,
  $limit: Int!,
  $offset: Int!
) {
  payEvents(
    where: { projectId: $projectId, chainId: $chainId }
    limit: $limit
    offset: $offset
  ) {
    items { ... }
    totalCount
  }
}

Parameters:

  • limit
    : Max items to return (default: 100, max: 1000)
  • offset
    : Number of items to skip

Special Endpoints

Participant Snapshots

Get historical participant balances at a specific timestamp. Useful for governance snapshots and airdrops.

POST https://bendystraw.xyz/{API_KEY}/participants

Request:

{
  "suckerGroupId": "0x...",
  "timestamp": 1704067200
}

Response:

{
  "participants": [
    {
      "address": "0x...",
      "balance": "1000000000000000000000",
      "chains": {
        "1": "600000000000000000000",
        "10": "400000000000000000000"
      }
    }
  ]
}

Complete JavaScript Example

const BENDYSTRAW_URL = 'https://bendystraw.xyz';
const API_KEY = process.env.BENDYSTRAW_API_KEY;

/**
 * Execute GraphQL query
 */
async function query(graphql, variables = {}) {
  const response = await fetch(`${BENDYSTRAW_URL}/${API_KEY}/graphql`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: graphql, variables })
  });

  const result = await response.json();

  if (result.errors) {
    throw new Error(result.errors[0].message);
  }

  return result.data;
}

/**
 * Get project stats
 */
async function getProject(projectId, chainId) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!) {
      project(projectId: $projectId, chainId: $chainId) {
        name
        handle
        owner
        balance
        volume
        volumeUsd
        tokenSupply
        paymentsCount
        contributorsCount
        suckerGroupId
      }
    }
  `, { projectId, chainId });

  return data.project;
}

/**
 * Get omnichain totals for sucker group
 */
async function getOmnichainStats(suckerGroupId) {
  const data = await query(`
    query($id: String!) {
      suckerGroup(id: $id) {
        volume
        volumeUsd
        balance
        tokenSupply
        contributorsCount
        projects_rel {
          chainId
          name
          balance
          volume
        }
      }
    }
  `, { id: suckerGroupId });

  return data.suckerGroup;
}

/**
 * Get recent payments
 */
async function getRecentPayments(projectId, chainId, limit = 20) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!, $limit: Int!) {
      payEvents(
        where: { projectId: $projectId, chainId: $chainId }
        orderBy: "timestamp"
        orderDirection: "desc"
        limit: $limit
      ) {
        items {
          timestamp
          from
          beneficiary
          amount
          amountUsd
          memo
          newlyIssuedTokenCount
        }
      }
    }
  `, { projectId, chainId, limit });

  return data.payEvents.items;
}

/**
 * Get top token holders
 */
async function getTopHolders(projectId, chainId, limit = 100) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!, $limit: Int!) {
      participants(
        where: { projectId: $projectId, chainId: $chainId }
        orderBy: "balance"
        orderDirection: "desc"
        limit: $limit
      ) {
        items {
          address
          balance
          creditBalance
          erc20Balance
          volume
        }
        totalCount
      }
    }
  `, { projectId, chainId, limit });

  return {
    holders: data.participants.items,
    total: data.participants.totalCount
  };
}

/**
 * Get participant balance
 */
async function getParticipant(projectId, chainId, address) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!, $address: String!) {
      participant(projectId: $projectId, chainId: $chainId, address: $address) {
        balance
        creditBalance
        erc20Balance
        volume
        volumeUsd
        paymentsCount
      }
    }
  `, { projectId, chainId, address });

  return data.participant;
}

/**
 * Get trending projects
 */
async function getTrendingProjects(chainId = null, limit = 10) {
  const where = chainId ? { chainId } : {};

  const data = await query(`
    query($where: ProjectWhereInput, $limit: Int!) {
      projects(
        where: $where
        orderBy: "trendingScore"
        orderDirection: "desc"
        limit: $limit
      ) {
        items {
          projectId
          chainId
          name
          handle
          trendingScore
          trendingVolume
          trendingPaymentsCount
        }
      }
    }
  `, { where, limit });

  return data.projects.items;
}

/**
 * Get historical snapshot for governance
 */
async function getSnapshot(suckerGroupId, timestamp) {
  const response = await fetch(`${BENDYSTRAW_URL}/${API_KEY}/participants`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ suckerGroupId, timestamp })
  });

  return await response.json();
}

/**
 * Get unified activity feed (all event types)
 */
async function getActivityFeed(projectId, chainId, limit = 50) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!, $limit: Int!) {
      activityEvents(
        where: { projectId: $projectId, chainId: $chainId }
        orderBy: "timestamp"
        orderDirection: "desc"
        limit: $limit
      ) {
        items {
          id
          timestamp
          txHash
          from
          type
          payEvent { amount, amountUsd, beneficiary, memo }
          cashOutTokensEvent { cashOutCount, reclaimAmount, holder }
          mintNftEvent { tierId, tokenId }
          sendPayoutsEvent { amount, amountPaidOut, fee }
          borrowLoanEvent { borrowAmount, collateral }
        }
      }
    }
  `, { projectId, chainId, limit });

  return data.activityEvents.items;
}

/**
 * Get active loans for a project
 */
async function getLoans(projectId, chainId, limit = 100) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!, $limit: Int!) {
      loans(
        where: { projectId: $projectId, chainId: $chainId }
        orderBy: "createdAt"
        orderDirection: "desc"
        limit: $limit
      ) {
        items {
          id
          borrowAmount
          collateral
          prepaidDuration
          prepaidFeePercent
          owner
          beneficiary
          createdAt
        }
        totalCount
      }
    }
  `, { projectId, chainId, limit });

  return {
    loans: data.loans.items,
    total: data.loans.totalCount
  };
}

/**
 * Get wallet portfolio across all projects
 */
async function getWalletPortfolio(address) {
  const data = await query(`
    query($address: String!) {
      wallet(address: $address) {
        address
        volume
        volumeUsd
        lastPaidTimestamp
        participants(limit: 100) {
          items {
            projectId
            chainId
            balance
            volume
            project { name, handle }
          }
        }
      }
    }
  `, { address });

  return data.wallet;
}

/**
 * Get NFT tier details with metadata
 */
async function getNFTTiers(projectId, chainId) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!) {
      nftTiers(
        where: { projectId: $projectId, chainId: $chainId }
        orderBy: "tierId"
        orderDirection: "asc"
        limit: 100
      ) {
        items {
          tierId
          price
          initialSupply
          remainingSupply
          category
          votingUnits
          resolvedUri
          metadata
          svg
        }
      }
    }
  `, { projectId, chainId });

  return data.nftTiers.items;
}

/**
 * Get historical project snapshots for charting
 */
async function getProjectHistory(projectId, chainId, limit = 100) {
  const data = await query(`
    query($projectId: Int!, $chainId: Int!, $limit: Int!) {
      projectMoments(
        where: { projectId: $projectId, chainId: $chainId }
        orderBy: "timestamp"
        orderDirection: "desc"
        limit: $limit
      ) {
        items {
          block
          timestamp
          volume
          volumeUsd
          balance
          trendingScore
        }
      }
    }
  `, { projectId, chainId, limit });

  return data.projectMoments.items;
}

// Example usage
async function main() {
  // Get project on Ethereum mainnet
  const project = await getProject(1, 1);
  console.log(`${project.name}: ${project.balance} wei balance`);

  // If omnichain, get aggregated stats
  if (project.suckerGroupId) {
    const omni = await getOmnichainStats(project.suckerGroupId);
    console.log(`Omnichain total: ${omni.volume} wei volume across ${omni.projects_rel.length} chains`);
  }

  // Get recent activity
  const payments = await getRecentPayments(1, 1, 5);
  console.log(`Last ${payments.length} payments:`);
  payments.forEach(p => {
    console.log(`  ${p.from.slice(0,8)}... paid ${p.amount} wei`);
  });

  // Get top holders
  const { holders, total } = await getTopHolders(1, 1, 10);
  console.log(`Top 10 of ${total} holders:`);
  holders.forEach((h, i) => {
    console.log(`  ${i + 1}. ${h.address.slice(0,8)}... - ${h.balance} tokens`);
  });
}

main().catch(console.error);

BendystrawClient Class

class BendystrawClient {
  constructor(apiKey, isTestnet = false) {
    this.baseUrl = isTestnet
      ? 'https://testnet.bendystraw.xyz'
      : 'https://bendystraw.xyz';
    this.apiKey = apiKey;
  }

  async query(graphql, variables = {}) {
    const response = await fetch(`${this.baseUrl}/${this.apiKey}/graphql`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: graphql, variables })
    });

    if (!response.ok) {
      throw new Error(`Bendystraw request failed: ${response.statusText}`);
    }

    const result = await response.json();
    if (result.errors) {
      throw new Error(result.errors[0].message);
    }

    return result.data;
  }

  // Convenience methods
  async getProject(projectId, chainId) { ... }
  async getSuckerGroup(id) { ... }
  async getPayments(projectId, chainId, limit) { ... }
  async getParticipants(projectId, chainId, limit) { ... }
  async getSnapshot(suckerGroupId, timestamp) { ... }
}

Server-Side Proxy

Since the API key must be kept secret, use a server-side proxy:

Next.js API Route

// pages/api/bendystraw.ts
export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const response = await fetch(
    `https://bendystraw.xyz/${process.env.BENDYSTRAW_API_KEY}/graphql`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(req.body)
    }
  );

  const data = await response.json();
  res.json(data);
}

Express Middleware

app.post('/api/bendystraw', async (req, res) => {
  const response = await fetch(
    `https://bendystraw.xyz/${process.env.BENDYSTRAW_API_KEY}/graphql`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(req.body)
    }
  );

  res.json(await response.json());
});

Error Handling

ErrorCauseSolution
Unauthorized
Invalid API keyVerify API key is correct
Rate limited
Too many requestsAdd backoff/retry logic
Invalid query
GraphQL syntax errorCheck query structure
Not found
Entity doesn't existVerify projectId/chainId
Timeout
Query too complexReduce limit, add filters

Gotchas & Common Pitfalls

V4 vs V5 Protocol Confusion

CRITICAL: V4 and V5 are completely different protocols with different contract addresses. Project #1 on V4 is NOT the same as Project #1 on V5. Never mix V4 and V5 addresses.

V5.0 vs V5.1 Contract Confusion

CRITICAL: Within V5, there are two contract versions: V5.0 and V5.1. Contracts that have both versions MUST NOT be mixed. A project using JBController5_1 MUST use JBMultiTerminal5_1.

Shared contracts (work with both V5.0 and V5.1):

ContractAddress
JBProjects
0x885f707efa18d2cb12f05a3a8eba6b4b26c8c1d4
JBTokens
0x4d0edd347fb1fa21589c1e109b3474924be87636
JBDirectory
0x0061e516886a0540f63157f112c0588ee0651dcf
JBSplits
0x7160a322fea44945a6ef9adfd65c322258df3c5e

V5.0 contracts (for revnets and older projects):

ContractAddress
JBController
0x27da30646502e2f642be5281322ae8c394f7668a
JBMultiTerminal
0x2db6d704058e552defe415753465df8df0361846
JBRulesets
0x6292281d69c3593fcf6ea074e5797341476ab428
REVDeployer
0x2ca27bde7e7d33e353b44c27acfcf6c78dde251d
JB721TiersHookDeployer
0x7e4f7bfeab74bbae3eb12a62f2298bf2be16fc93

V5.1 contracts (for new projects):

ContractAddress
JBController5_1
0xf3cc99b11bd73a2e3b8815fb85fe0381b29987e1
JBMultiTerminal5_1
0x52869db3d61dde1e391967f2ce5039ad0ecd371c
JBRulesets5_1
0xd4257005ca8d27bbe11f356453b0e4692414b056
JBOmnichainDeployer5_1
0x587bf86677ec0d1b766d9ba0d7ac2a51c6c2fc71
JB721TiersHookDeployer5_1
0x7e6e7db5081c59f2df3c83b54eb0c4d029e9898e

Determining project version: Query

JBDirectory.controllerOf(projectId)
and compare:

  • 0x27da30646502e2f642be5281322ae8c394f7668a
    = V5.0 (use JBMultiTerminal, JBRulesets)
  • 0xf3cc99b11bd73a2e3b8815fb85fe0381b29987e1
    = V5.1 (use JBMultiTerminal5_1, JBRulesets5_1)

See

/jb-v5-v51-contracts
for complete reference and code patterns.

Token Symbol Confusion

CRITICAL: The

tokenSymbol
field in Bendystraw returns the base/accounting token (e.g., "ETH" or "USDC"), NOT the project's issued ERC20 token symbol (e.g., "NANA" for Bananapus).

To get the project's issued token symbol, you must query the blockchain directly:

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

// JBTokens address (same on all V5 chains)
const JB_TOKENS = '0x4d0edd347fb1fa21589c1e109b3474924be87636'

const TOKEN_ABI = [
  {
    name: 'tokenOf',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'projectId', type: 'uint256' }],
    outputs: [{ name: '', type: 'address' }],
  },
  {
    name: 'symbol',
    type: 'function',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ name: '', type: 'string' }],
  },
]

async function getProjectTokenSymbol(projectId: number, chainId: number) {
  const client = createPublicClient({ chain: mainnet, transport: http() })

  // Get the token address from JBTokens
  const tokenAddress = await client.readContract({
    address: JB_TOKENS,
    abi: TOKEN_ABI,
    functionName: 'tokenOf',
    args: [BigInt(projectId)],
  })

  // Zero address means no ERC20 deployed yet (using credits only)
  if (tokenAddress === '0x0000000000000000000000000000000000000000') {
    return null
  }

  // Get symbol from the token contract
  return await client.readContract({
    address: tokenAddress,
    abi: TOKEN_ABI,
    functionName: 'symbol',
  })
}

GraphQL Type Inconsistencies

Different queries expect different GraphQL types for the same conceptual values. This causes silent failures if you use the wrong type:

QueryprojectId typechainId typeversion type
project()
Float!
Float!
Float!
payEvents()
Int!
Int!
Int!
participants()
Int!
Int
-
projects()
---

Example of the issue:

# This works (project query uses Float)
query GetProject($projectId: Float!, $chainId: Float!, $version: Float!) {
  project(projectId: $projectId, chainId: $chainId, version: $version) { ... }
}

# This fails silently if you pass Float instead of Int
query GetPayEvents($projectId: Int!, $chainId: Int!, $version: Int!) {
  payEvents(where: { projectId: $projectId, chainId: $chainId, version: $version }) { ... }
}

Best practice: Check the schema or use the GraphQL playground to verify expected types for each query.

SuckerGroup Cross-Chain Aggregation

For omnichain projects, use

suckerGroupId
to get aggregated data across all chains instead of querying each chain separately:

// INEFFICIENT: Query each chain separately
const chains = [1, 10, 8453, 42161]
const balances = await Promise.all(
  chains.map(chainId => getProjectBalance(projectId, chainId))
)
const totalBalance = balances.reduce((sum, b) => sum + b, 0n)

// EFFICIENT: Use suckerGroup for cross-chain totals
const project = await getProject(projectId, chainId)
if (project.suckerGroupId) {
  const group = await getSuckerGroup(project.suckerGroupId)
  // group.balance is already the cross-chain total
  // group.projects_rel has per-chain breakdown
}

The

suckerGroup
query returns:

  • Pre-aggregated totals (
    balance
    ,
    volume
    ,
    tokenSupply
    , etc.)
  • Per-chain breakdown via
    projects_rel
  • Consistent data without race conditions from parallel queries

ETH vs USDC Project Currency

CRITICAL: Projects can use different base currencies (ETH or USDC). The

amount
,
balance
, and
volume
fields use different decimal precision depending on the currency:

CurrencyDecimalsCode
ETH181
USDC62

The currency info is available from the

suckerGroup
or
participants
query:

query GetSuckerGroup($id: String!) {
  suckerGroup(id: $id) {
    projects_rel {
      projectId
      chainId
      decimals       # 18 for ETH, 6 for USDC
      currency       # 1 for ETH, 2 for USDC
      balance
    }
  }
}

Formatting amounts correctly:

import { formatUnits } from 'viem'

function formatAmount(wei, decimals, currency) {
  const num = parseFloat(formatUnits(BigInt(wei), decimals))
  const symbol = currency === 2 ? 'USDC' : 'ETH'
  // USDC uses fewer decimal places for display
  const precision = currency === 2 ? 2 : 4
  return `${num.toFixed(precision)} ${symbol}`
}

// Example: USDC project (6 decimals)
formatAmount('1000000', 6, 2)    // "1.00 USDC"

// Example: ETH project (18 decimals)
formatAmount('1000000000000000000', 18, 1)  // "1.0000 ETH"

Common mistake: Using

formatEther()
(assumes 18 decimals) for USDC projects will show wildly incorrect values. Always detect the currency first and use
formatUnits()
with the correct decimals.


Best Practices

  1. Use server-side proxy - Never expose API key in frontend code
  2. Cache responses - Data updates every ~1 minute, cache accordingly
  3. Query only needed fields - Reduces payload size and latency
  4. Use pagination - Don't fetch thousands of records at once
  5. Handle nulls - Fields like
    volumeUsd
    ,
    handle
    may be null
  6. Consider freshness - Indexer may lag 1-2 blocks behind chain
  7. Use filters - Narrow queries by chainId, projectId when possible
  8. Always include version - V4 and V5 projects with the same projectId are completely different
  9. Use BigInt for USD values - volumeUsd is 18 decimals; parseFloat loses precision on large values
  10. Group by projectId + version - Same project across chains should be grouped, but different versions should not
  11. Check GraphQL types - Different queries expect Float vs Int for the same fields
  12. Use suckerGroup for cross-chain data - More efficient than querying each chain separately
  13. Fetch token symbols from chain - Bendystraw's tokenSymbol is the accounting token, not the project's issued token
  14. Detect currency before formatting - Use
    formatUnits(wei, decimals)
    not
    formatEther(wei)
    since USDC projects use 6 decimals

Use Cases

  • Project dashboards - Display stats, activity, holders
  • Unified activity feeds - Use
    activityEvents
    for all-in-one activity streams
  • Governance snapshots - Get token balances at specific timestamps
  • Analytics - Track trends, volumes, contributor growth via
    projectMoments
  • Portfolio tracking - Use
    wallet
    query for user positions across all projects
  • Omnichain aggregation - Unified view via
    suckerGroup
    and
    suckerGroupMoments
  • Airdrops - Generate recipient lists from holder data
  • Loan dashboards - Track RevLoans borrowing, repayments, liquidations
  • NFT galleries - Full tier metadata via
    nftTiers
    including SVGs and pricing
  • Cross-chain tracking - Monitor token bridging via
    suckerTransactions
  • Treasury management - Track payouts and allowance usage

Related Skills

  • /jb-relayr
    - Execute multi-chain transactions
  • /jb-omnichain-ui
    - Build UIs with Bendystraw data
  • /jb-query
    - Direct on-chain queries via cast/ethers
  • /jb-revloans
    - RevLoans protocol integration
  • /jb-loan-queries
    - Loan-specific query patterns
  • /jb-nft-gallery-ui
    - Build NFT galleries with tier data