Encode metadata for Juicebox V5 terminal payments using JBMetadataResolver. Covers Permit2 gasless ERC20 payments, 721 hook tier selection, and combining multiple metadata types. Use when seeing AllowanceExpired errors, metadata extraction returns zeros, specifying NFT tiers to mint, or Tenderly shows exists false at getDataFor call.
JBMetadataResolver: Pay Metadata Encoding
Overview
Juicebox V5 uses JBMetadataResolver to pass structured data through the metadata parameter of pay(), addToBalance(), and other terminal functions. Multiple extensions (Permit2, 721 hook, buyback hook, etc.) can read their specific data from a single metadata blob using a lookup table format.
Key concept: Each extension has a unique 4-byte ID. The metadata contains a lookup table mapping IDs to data offsets, allowing each extension to find its data without knowing about other extensions.
When to Use This Skill
Implementing gasless ERC20 payments via Permit2 (single-transaction UX)
Specifying which NFT tiers to mint when paying a 721 hook project
Seeing "AllowanceExpired" error from Permit2 contract
Tenderly shows exists: false or all-zeros at getDataFor call
Combining multiple metadata types in one payment (e.g., Permit2 + tier selection)
Critical Rule: Use the Official Library
ALWAYS use juicebox-metadata-helper for metadata construction. Manual construction has subtle bugs:
npm install juicebox-metadata-helper
The library handles:
Correct offset calculation (in words, not bytes)
Proper padding to 32-byte boundaries
Lookup table format matching JBMetadataResolver exactly
Metadata Type 1: Permit2 (Gasless ERC20 Payments)
Permit2 allows single-transaction ERC20 payments without separate approve transactions.
Swap Terminal Registries
Two swap terminal registries exist, deployed at the same address on all chains:
Registry
Address
TOKEN_OUT
Purpose
JBSwapTerminalRegistry
0x60b4f5595ee509c4c22921c7b7999f1616e6a4f6
NATIVE_TOKEN (ETH)
Swaps incoming tokens → ETH
JBSwapTerminalUSDCRegistry
0x1ce40d201cdec791de05810d17aaf501be167422
USDC
Swaps incoming tokens → USDC
Choose based on what the project should RECEIVE after the swap, not what the user pays with.
Step 1: Compute the Permit2 Metadata ID
CRITICAL: Use ethers.js for ID computation. Viem's byte handling can have subtle issues with the XOR operation.
import { ethers } from'ethers'importtype { Address } from'viem'functioncomputePermit2MetadataId(terminalAddress: Address): string {
// Use ethers to match Solidity's bytes20 XOR exactlyconst purposeHash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes('permit2'))
// Get first 20 bytes of hash (40 hex chars after 0x)const purposeBytes20 = purposeHash.slice(0, 42)
// Terminal address is already 20 bytesconst terminalBytes20 = terminalAddress.toLowerCase()
// XOR as BigNumbers - matches Solidity's bytes20 ^ bytes20const purposeBN = ethers.BigNumber.from(purposeBytes20)
const terminalBN = ethers.BigNumber.from(terminalBytes20)
const xorResult = purposeBN.xor(terminalBN)
// Get first 4 bytes (8 hex chars) - matches Solidity's bytes4(...)return xorResult.toHexString().slice(0, 10)
}
Step 2: Encode JBSingleAllowance Struct
CRITICAL: Must encode as a TUPLE, not individual parameters!
When paying a project with a 721 hook, you can specify which NFT tiers to mint.
The 721 Hook Metadata ID
Unlike Permit2, the 721 hook ID is NOT XOR'd with the contract address. It's a static ID:
import { ethers } from'ethers'// Static ID - same for all 721 hooksconstJB721_HOOK_ID = '0x' + ethers.utils.keccak256(
ethers.utils.toUtf8Bytes('JB721TiersHook')
).slice(2, 10) // First 4 bytes
721 Hook Data Format
The data payload is:
allowOverspending (bool) - If true, excess payment beyond tier prices goes to token minting