| name | jb-terminal-selection |
| description | Dynamic terminal selection for Juicebox V5 payments. Use when: (1) building payment UIs that support
multiple tokens (ETH/USDC), (2) encountering JBMultiTerminal_TokenNotAccepted error, (3) paying a
project that uses ETH-only accounting with non-ETH tokens, (4) implementing cross-token payments
where the project may not directly accept the user's payment token. Covers JBDirectory.primaryTerminalOf()
querying, JBSwapTerminal fallback logic, and permit2 integration with correct terminal addresses.
|
Dynamic Terminal Selection for Juicebox V5 Payments
Problem
When paying a Juicebox V5 project, users may want to pay with tokens (e.g., USDC) that the project
doesn't directly accept in its accounting context. Sending such payments to JBMultiTerminal results
in a JBMultiTerminal_TokenNotAccepted(token) revert.
Common symptom: Transaction simulation shows "likely to fail" after permit2 signing, with the
TokenNotAccepted error in Tenderly or other simulation tools.
Context / Trigger Conditions
Apply this pattern when:
- Building a payment UI that supports multiple tokens (ETH, USDC, etc.)
- A project uses ETH accounting context but users want to pay with USDC
- You see
JBMultiTerminal_TokenNotAccepted errors in transaction simulations
- MetaMask shows "This transaction is likely to fail" after permit2 signing
- You need to determine which terminal to use at runtime
Solution
Core Concept
Query JBDirectory.primaryTerminalOf(projectId, tokenAddress) to discover which terminal
accepts payments for a given token. If no terminal is registered (returns zero address),
use JBSwapTerminal which automatically swaps the payment token to what the project accepts.
Implementation
import { type PublicClient, type Address, zeroAddress } from 'viem'
const JB_SWAP_TERMINAL: Record<number, Address> = {
1: '0x259385b97dfbd5576bd717dc7b25967ec8b145dd',
10: '0x73d04584bde126242c36c2c7b219cbdec7aad774',
8453: '0x4fd73d8b285e82471f08a4ef9861d6248b832edd',
42161: '0x483c9b12c5bd2da73133aae30642ce0008c752ad',
}
const JB_DIRECTORY = '0x0061e516886a0540f63157f112c0588ee0651dcf'
const JB_DIRECTORY_ABI = [
{
name: 'primaryTerminalOf',
type: 'function',
stateMutability: 'view',
inputs: [
{ name: 'projectId', type: 'uint256' },
{ name: 'token', type: 'address' },
],
outputs: [{ name: '', type: 'address' }],
},
] as const
type TerminalType = 'multi' | 'swap'
interface PaymentTerminal {
address: Address
type: TerminalType
}
async function getPaymentTerminal(
client: PublicClient,
chainId: number,
projectId: bigint,
paymentToken: Address
): Promise<PaymentTerminal> {
const terminal = await client.readContract({
address: JB_DIRECTORY,
abi: JB_DIRECTORY_ABI,
functionName: 'primaryTerminalOf',
args: [projectId, paymentToken],
})
const swapTerminal = JB_SWAP_TERMINAL[chainId]
if (terminal === zeroAddress) {
return { address: swapTerminal, type: 'swap' }
}
const isSwapTerminal = terminal.toLowerCase() === swapTerminal?.toLowerCase()
return {
address: terminal,
type: isSwapTerminal ? 'swap' : 'multi'
}
}
Permit2 Integration
When using permit2 for token approvals, the metadata ID computation must use the correct
terminal address as the spender:
function computePermit2MetadataId(terminalAddress: Address): `0x${string}` {
const permit2Hash = keccak256(toBytes('permit2'))
const terminalBytes = terminalAddress.slice(0, 42)
const hashBytes = permit2Hash.slice(0, 42)
const xorResult = BigInt(terminalBytes) ^ BigInt(hashBytes)
const bytes4 = (xorResult >> 128n) & 0xffffffffn
return `0x${bytes4.toString(16).padStart(8, '0')}`
}
Usage in Payment Flow
async function pay(projectId: string, amount: string, token: 'ETH' | 'USDC') {
const tokenAddress = token === 'ETH'
? '0x000000000000000000000000000000000000EEEe'
: USDC_ADDRESSES[chainId]
const terminal = await getPaymentTerminal(
publicClient,
chainId,
BigInt(projectId),
tokenAddress
)
if (token !== 'ETH') {
const permit = await signPermit2({
spender: terminal.address,
token: tokenAddress,
amount,
})
}
await walletClient.writeContract({
address: terminal.address,
abi: terminal.type === 'swap' ? JB_SWAP_TERMINAL_ABI : JB_MULTI_TERMINAL_ABI,
functionName: 'pay',
args: [projectId, tokenAddress, amount, beneficiary, minTokens, memo, metadata],
})
}
Verification
- Query
primaryTerminalOf for ETH → should return JBMultiTerminal address
- Query
primaryTerminalOf for USDC on ETH-only project → should return zero address
- Use SwapTerminal when zero address returned
- Transaction simulation should no longer show
TokenNotAccepted error
Example
Scenario: User wants to pay NANA (Project ID 1) with USDC on Base. NANA only uses ETH accounting.
const terminal = await getPaymentTerminal(
publicClient,
8453,
1n,
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
)
Notes
- JBSwapTerminal swaps tokens via Uniswap before crediting the project
- The swap uses TWAP pricing with slippage protection
- Projects can explicitly register JBSwapTerminal for tokens they want to accept via swaps
- Some projects register JBMultiTerminal for multiple tokens (e.g., both ETH and USDC)
- Always query at runtime; terminal registrations can change
Related Skills
/jb-v5-impl - Deep dive into terminal mechanics and payment flow internals
/jb-terminal-wrapper - Pattern for wrapping terminals with custom logic
/jb-v5-api - Core terminal interface signatures
/jb-query - Querying project state from blockchain
References