On this page

Project intents

A project intent is a signed, frozen Juicebox V6 project launch: the contract calls for each chain plus the publishing client's own form document, stored by Center. Publishing costs one wallet signature and no transaction. The project exists from the moment it is published: it has an id, a page, and a row in search. It becomes an on-chain project when someone deploys it, either at Center's expense on the sponsored rollups or by paying for the transaction themselves.

This guide is the canonical description of the flow. It covers who may call, the exact envelope, publishing, reading, sponsored deploys, self-paid deploys, and the mistakes that cost people a working omnichain project.

Say "project" in user-facing copy, with a "Deploys on first use" label. The word "intent" belongs in API documentation, not in product copy: it suggests something still negotiable, and a published intent cannot be edited, replaced or withdrawn.

What an intent is

Property Value
Content format, deploymentVersion, chainIds, 1 to 4 deploymentCalls per chain, and a jb document
Identity A UUID assigned by Center, plus a contentHash over the canonical envelope
Authenticity A publisher signature over a message that quotes the content hash
Mutability None. There is no edit, replace or withdraw route. Publish a new intent instead
Status undeployed until a deployment is recorded for any chain, then deployed

The per-chain to address and the complete calldata are part of the signed content, so the launch is directly executable and independently verifiable by anyone: nobody has to re-derive time-sensitive arguments later. Center never edits the calldata and never invents a call.

A signature on an intent authorizes Center to store and publish frozen calldata. It is not transaction approval and it moves no funds. Deployment is a separate, funded transaction.

Who may call

The intent routes (/v1/intents* and /v1/search) are gated by browser Origin. A request whose Origin header is not on Center's first-party list is refused with 403 and {"error":{"code":"forbidden_origin"}}. There is no bearer token for these routes and no public fallback. This is tighter than /v1/rpc, which admits any origin, including none, as a public caller under its own budget.

Two ways in for a new integrator:

  1. Ask for an origin. Center's first-party list lives in src/firstParty.ts in the mejango/jbcenter repository: one production entry per app, and several localhost and dev entries per app in the development list. Open a pull request adding your origin, or ask the maintainers. Production entries today are https://juicebox.money, https://revnet.money, https://eth.shop, https://succulent.money, https://homerun.money, https://sticky.center and https://beep.biz.
  2. Use the MCP tools. Connect any Streamable HTTP MCP client to https://juicebox.center/mcp. jb_prepare_intent, jb_publish_intent, jb_get_intent and jb_deploy_intent run server-side inside Center, so they need no origin, no REST account and no bot grant. The MCP never holds a key and never signs: you bring the signature.

Server-to-server callers that are not the MCP still need an allow-listed origin, and must send it as a real Origin header.

The envelope

{
  "format": "juicebox.money/v1",
  "deploymentVersion": "6",
  "chainIds": [8453, 42161],
  "deploymentCalls": [
    { "chainId": 8453, "to": "0x...", "data": "0x..." },
    { "chainId": 42161, "to": "0x...", "data": "0x..." }
  ],
  "jb": { "v": 1, "name": "Public goods garden", "owner": "0x...", "chainIds": [8453, 42161] }
}
Field Type Rules
format string <host>/<label>: exactly one slash. Host part matches [a-z0-9.-]{1,80}, label matches [a-zA-Z0-9._-]{1,32} — the label may not contain a slash. Identifies the publishing client
deploymentVersion string "6". 1 to 64 characters
chainIds number[] 1 to 16 unique positive integers. Center sorts them ascending before hashing
deploymentCalls array 1 to 4 calls, each {chainId, to, data}, for each member of chainIds, between 4 bytes and 4 MiB each. The last call for a chain is its launch call; see "Setup calls". to is checksummed; data is stored lowercased and matched case-insensitively when a self-paid deployment is verified. Center sorts by chainId before hashing and keeps each chain's calls in the order they were signed
jb object The publishing client's own document. Any JSON object, nesting at most 64 levels

Existing publishers use juicebox.money/v1 and revnet.money/v1. An app that publishes more than one kind of deployment distinguishes them in the label, for example homerun.money/fund.v1 or beep.biz/terminal.v1. A second slash is rejected with 400 and format must look like juicebox.money/v1.

Launch entry points

Each deploymentCall.to is one of a small set of canonical V6 launch entry points. Look up their per-chain addresses at GET /api/v1/catalog/contracts or in ./CONTRACTS.md; Center does not invent a call or a target.

  • JBController.launchProjectFor
  • JB721TiersHookProjectDeployer.launchProjectFor
  • JBOmnichainDeployer.launchProjectFor
  • REVDeployer.deployFor, with revnetId 0 for a first-time launch
  • HomerunDeployer.launchFundFor

Setup calls

A chain may carry more than one call. The last call for a chain is the launch call; every call before it is a setup call. One call per chain is a launch call, so every intent published before this rule existed is unchanged.

A setup call creates a Safe. Center pays for it, so it is the only thing a setup call may do:

Part Required value
to 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67, the Safe 1.4.1 proxy factory. Its runtime code is checked on the chain before the sponsor pays
data createProxyWithNonce(singleton, initializer, saltNonce), encoded exactly
singleton 0x41675C099F32341bf84BFc5382aF534df5C7461a
initializer setup(owners, threshold, address(0), 0x, 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99, address(0), 0, address(0))
owners 1 to 20 unique nonzero addresses
threshold 1 to owners.length

Anything else is refused with 400 and a message naming the call index, for example deploymentCalls[0].to must be the canonical Safe proxy factory. A chain accepts at most four calls, so at most three Safes. Two byte-identical setup calls on one chain are refused with deploymentCalls[1] repeats a setup call on its chain, because the second one would create nothing and the sponsor would pay for its revert.

A Safe 1.4.1 address depends only on the factory, the singleton, the initializer and the salt nonce. It never depends on the sender or the time. So the Safe creation and the launch are independent: the project can be transferred to the Safe address before the Safe exists, and the Safe accepts the project whenever it is created. A Safe that already exists at the predicted address costs nothing: Center drops that call from the bundle. A Safe creation that reverts does not fail the chain; the project is owned by the predicted address either way and anyone can create the Safe later.

The sponsored deploy sends one bundle for all chains. Center numbers a chain's entries in that bundle, so the execution service sends the chain's Safe creations before its launch. Nothing depends on that order; the numbering is what the service requires to send them. The service answers with one identifier per call, in an order of its own, so Center reads the bundle back and binds each identifier to the one submitted call whose chain, contract, calldata, value and number all match it. Each chain's row is sent on its launch transaction hash and confirmed when that transaction succeeds and carries the JBProjects.Create event. transactionHash on the row and on the recorded deployment is always the launch transaction.

jb.safes

Center stores jb as it is given. The convention for rendering a Safe is:

"safes": [
  {
    "role": "owner",
    "address": "0x...",
    "owners": ["0x...", "0x..."],
    "threshold": 2,
    "saltNonce": "0x..."
  }
]

A Homerun fund that creates its own 2-of-2 owner Safe

import {
  buildSafeDeploymentCalls,
  predictSafeAddress,
  SAFE_PROXY_CREATION_CODE,
} from "@bananapus/nana-sdk-core/safe";

const policy = {
  owners: [alice, bob],
  threshold: 2,
  saltNonce,
  proxyCreationCode: SAFE_PROXY_CREATION_CODE,
};
// A plan carries the address it predicts; the SDK re-derives it and refuses a mismatch.
const owner = { ...policy, address: predictSafeAddress(policy) };

const deploymentCalls = chainIds.flatMap((chainId) => [
  ...buildSafeDeploymentCalls([owner]).map((call) => ({
    chainId,
    to: call.target,
    data: call.callData,
  })),
  {
    chainId,
    to: homerunDeployer[chainId],
    data: encodeFunctionData({
      abi: homerunAbi,
      functionName: "launchFundFor",
      args: [owner.address, /* the rest of the fund */],
    }),
  },
]);

const envelope = {
  format: "homerun.money/fund.v1",
  deploymentVersion: "6",
  chainIds,
  deploymentCalls,
  jb: {
    ...formValues,
    owner: owner.address,
    safes: [
      {
        role: "owner",
        address: owner.address,
        owners: owner.owners,
        threshold: owner.threshold,
        saltNonce: owner.saltNonce,
      },
    ],
  },
};

jb.owner is the Safe address, so the intent appears under GET /v1/search?owner=. A page that lists "my projects" also filters by publisher, because the publishing wallet is an owner of the Safe, not the project's owner.

jb conventions

Center treats jb as opaque, but indexes a few conventional fields so that search and lists work without the client re-reading every envelope. Fields are read from the root of jb, or from jb.data when jb.app is "revnet.money".

jb field Indexed as Notes
name name Trimmed to 100 characters. Missing means the stored name is Untitled project
description description Trimmed to 10000 characters
tagline or projectTagline tagline Trimmed to 200 characters
tags tags At most 10 strings of at most 30 characters
logoUri, logo or links.logoUri logoUri Use an ipfs:// URI: the first-party webclients do not render HTTPS logos
owner owner Indexed, stored checksummed, when it is an address string; otherwise not indexed. This is what GET /v1/search?owner= matches
chainIds or chains not indexed When present it must equal the envelope's chainIds, or the publish is refused

Also set app (your client's short name) and, when your client has more than one product, kind. Everything else is yours.

Content hash and signing message

The content hash is keccak256 over the canonical JSON of the normalized envelope: object keys sorted lexicographically at every level, no whitespace, chain ids and calls already sorted.

The message to sign is exactly:

Juice Central project intent
Version: 1
Content hash: 0x<64 hex characters>

Never build that string yourself. Ask POST /v1/intents/message for it, so a change in Center's normalization can never leave you signing something Center will not store.

One sender per intent

Sucker, ERC-20 and 721-hook salts hash _msgSender() on every chain. Every chain of one intent must be deployed by the same sender, or the deployments do not link into one omnichain project: they land as unrelated same-named projects on separate chains, and nothing can repair that.

There are exactly two valid senders for a whole intent, never mixed:

  • Center's sponsor key deploys every chain. It signs each chain's launch as an ERC-2771 forward request, so _msgSender() on the destination is the sponsor whether Center pays (POST /v1/intents/:id/deploy) or the visitor pays (POST /v1/intents/:id/relay).
  • One wallet deploys every chain itself, paying each one and recording it.

Center enforces the boundary one chain at a time, and remembers which sender made each chain. A recorded deployment carries forwarded: true when the committed call came from the canonical ERC2771Forwarder with Center's sponsor as the appended sender, false when any other sender made it, including a wallet that sent the committed call itself. A chain that already has a deployment is refused by both routes, and one deployment that is not forwarded closes both routes for the whole intent with 409 mixed_sender. Recording it also retires, in the same database transaction, every queued sponsored row of the intent on every chain that has no bundle yet (failed, mixed sender), so a sponsor that becomes funded later can never launch a second project; the worker's claim refuses such rows too, and a lane already running on one cannot attach its bundle. A row that already has a bundle is left alone, because its prepayment may have left the sponsor key: the worker resumes it. So deploy a chain Center does not sponsor with a relay request, never from your own wallet: sent from any other address it produces different token and sucker salts.

On the sponsored path Center's sponsor key signs an ERC-2771 forward request per chain against the canonical ERC2771Forwarder from the V6 manifest, after checking that the call's target trusts that forwarder. The deployment calls themselves carry no value: the project creation fee is read live from JBProjects.creationFee() at deploy time and attached to the forwarded request, funded by the Relayr prepayment. A fee above Center's ceiling of 100000000000000 wei (0.0001 ETH) fails the lane instead of spending more than the reservation.

Publish end to end

Step 1: ask for the message

curl -X POST https://juicebox.center/v1/intents/message \
  -H 'origin: https://juicebox.money' \
  -H 'content-type: application/json' \
  --data '{
    "format":"juicebox.money/v1",
    "deploymentVersion":"6",
    "chainIds":[8453],
    "deploymentCalls":[{
      "chainId":8453,
      "to":"0x3333333333333333333333333333333333333333",
      "data":"0x12345678"
    }],
    "jb":{"v":1,"name":"Public goods garden","owner":"0x1111111111111111111111111111111111111111","chains":[8453]}
  }'
{
  "contentHash": "0x9a...",
  "message": "Juice Central project intent\nVersion: 1\nContent hash: 0x9a...",
  "envelope": { "format": "juicebox.money/v1", "deploymentVersion": "6", "chainIds": [8453], "deploymentCalls": [{ "chainId": 8453, "to": "0x3333333333333333333333333333333333333333", "data": "0x12345678" }], "jb": { "v": 1, "name": "Public goods garden", "owner": "0x1111111111111111111111111111111111111111", "chains": [8453] } }
}

Step 2: apply the guard, then sign

Before you sign, check two things. Every publisher applies this guard; it is the only thing standing between a compromised or confused response and a signature over calldata the user never saw.

  1. The returned envelope equals the envelope you built. Compare canonically: sort object keys, lowercase every hex string, and compare the serialized result. Do not compare with === on objects and do not trust field order.

  2. The returned message equals, character for character, the message for the envelope you built:

    Juice Central project intent
    Version: 1
    Content hash: <hash>

    where <hash> is the keccak256 of the canonical JSON of that envelope. Never build this string yourself to sign; build it only to compare, and sign the message Center returned once the two match.

If either check fails, refuse to sign and surface the mismatch. Do not retry silently.

Sign with personal_sign from an externally owned account. Center recovers the signer from the signature and compares it to publisher: contract signatures are not verified today, so an ERC-1271 smart account and an ERC-6492 wrapped signature from an undeployed account are both refused with 400. A client whose connector cannot sign messages, such as a passkey wallet, needs an external wallet for this step.

Step 3: publish

curl -X POST https://juicebox.center/v1/intents \
  -H 'origin: https://juicebox.money' \
  -H 'content-type: application/json' \
  --data '{
    "format":"juicebox.money/v1",
    "deploymentVersion":"6",
    "chainIds":[8453],
    "deploymentCalls":[{"chainId":8453,"to":"0x3333333333333333333333333333333333333333","data":"0x12345678"}],
    "jb":{"v":1,"name":"Public goods garden","owner":"0x1111111111111111111111111111111111111111","chains":[8453]},
    "publisher":"0x1111111111111111111111111111111111111111",
    "signature":"0x..."
  }'

201 with the stored intent the first time. 200 with the same intent when the same publisher re-sends the same content: publishing is idempotent per (publisher, contentHash).

The same flow with the SDK

JBCenterClientOptions has no origin option: a bare createJBCenterClient() only works from an allow-listed browser origin, where the browser sets Origin itself. A server-side caller supplies its own fetch that sets the header:

import {
  createJBCenterClient,
  createJBCenterDeploymentCall,
  publishSignedIntent,
} from "@bananapus/nana-sdk-core/jbcenter";

const client = createJBCenterClient({
  fetch: (input, init) =>
    fetch(input, {
      ...init,
      headers: { ...init?.headers, origin: "https://your-app.example" },
    }),
});

const deploymentCalls = chainIds.map((chainId) =>
  createJBCenterDeploymentCall({
    chainId,
    address, // the launch entry point on this chain; see "Launch entry points" above
    abi,
    functionName,
    args,
  }),
);
const envelope = {
  format: "juicebox.money/v1",
  deploymentVersion: "6",
  chainIds,
  deploymentCalls,
  jb: formValues,
};

const intent = await publishSignedIntent(
  client,
  envelope,
  (message) => walletClient.signMessage({ account, message }),
  { publisher: account.address },
);

publishSignedIntent is the guard from step 2 as one call: it prepares the intent, checks that Center's prepared envelope matches the one built here and that the prepared message equals, character for character, the whole signing message it builds for that envelope's own content hash, and only then calls sign and publishes. Either check failing throws JBCenterIntentMismatchError instead of reaching a wallet.

publishSignedIntent, describeCenterRefusal, the homerun-fund decode flavor and the owner and publisher search filters need @bananapus/nana-sdk-core 2.8.0 or newer.

Publish limits and refusals

Status Code Meaning
400 bad_request The envelope failed normalization, or the signature does not match publisher and the content
403 forbidden_origin The Origin header is absent or not first-party
413 body_too_large The request body exceeded 16800000 bytes
429 publish_limit PUBLISH_PER_PUBLISHER_PER_DAY publishes per publisher per day (default 20), or PUBLISH_PER_IP_PER_HOUR per IP per hour (default 60). Retry-After is 86400 or 3600
429 storage_limit The calling client exceeded its stored intent count or byte budget

As of 2026-09-21, production runs PUBLISH_PER_PUBLISHER_PER_DAY at 500 and PUBLISH_PER_IP_PER_HOUR at 500.

An MCP caller spends the hourly limit twice: once under its own publisher key, and once in a bucket every MCP publisher shares. A publisher key is free to mint, so the shared bucket is what bounds the assistant as a whole. Its stored intents and bytes count against the assistant's own lifetime caps — MCP_MAX_INTENTS, default 100000, and MCP_MAX_STORAGE_BYTES, default 10 GiB — rather than against a browser client's, and its sponsored deploys draw on the assistant's own daily slice of the sponsorship budget, below.

Read and list

One intent

GET /v1/intents/:id

200 with the intent, 400 for an id that is not a UUID, 404 with not_found otherwise.

{
  "id": "a7396c7e-b13f-4ca8-9f06-96f36ab22c3a",
  "status": "undeployed",
  "contentHash": "0x9a...",
  "envelope": { "format": "juicebox.money/v1", "deploymentVersion": "6", "chainIds": [8453], "deploymentCalls": [{ "chainId": 8453, "to": "0x...", "data": "0x..." }], "jb": {} },
  "publisher": "0x1111111111111111111111111111111111111111",
  "signature": "0x...",
  "name": "Public goods garden",
  "description": null,
  "tagline": null,
  "tags": [],
  "logoUri": null,
  "owner": "0x1111111111111111111111111111111111111111",
  "createdAt": "2026-09-21T00:00:00.000Z",
  "deployments": [],
  "deploys": []
}

deployments holds recorded on-chain results, write-once per chain:

Field Meaning
chainId The chain the project was created on
projectId The project id the canonical JBProjects.Create event carried
transactionHash The transaction Center verified
forwarded True when the launch was sent through Center's forwarder with Center's sponsor as the appended sender, which is what lets Center sponsor or relay the remaining chains; false for a deployment any other sender made
createdAt When Center recorded it

deploys holds sponsored-deploy rows and is always present, empty until a sponsored deploy is requested: {chainId, status, transactionHash, bundleUuid, error, createdAt, updatedAt} with status one of queued, sent, confirmed, failed. error is always a coded, authored message capped at 300 characters with secrets scrubbed before it is stored, and it is set on a waiting row as well as a failed one.

GET /v1/search?q=climate&owner=0x1111...&publisher=0x2222...&limit=20&cursor=20
Parameter Rules
q At most 200 characters. Empty or absent lists recent intents newest first
owner An address. Matches the indexed jb.owner, case-insensitively
publisher An address. Matches the signing publisher, case-insensitively
limit 1 to 100, default 20
cursor The nextCursor from the previous page: a non-negative integer offset

All four filters combine. An owner or publisher that is not an address is refused with 400.

{
  "items": [
    {
      "source": "jbcenter",
      "status": "undeployed",
      "intentId": "a7396c7e-b13f-4ca8-9f06-96f36ab22c3a",
      "contentHash": "0x9a...",
      "format": "juicebox.money/v1",
      "deploymentVersion": "6",
      "chainIds": [8453],
      "publisher": "0x2222222222222222222222222222222222222222",
      "name": "Public goods garden",
      "description": null,
      "tagline": null,
      "tags": [],
      "logoUri": null,
      "owner": "0x1111111111111111111111111111111111111111",
      "createdAt": "2026-09-21T00:00:00.000Z"
    }
  ],
  "totalCount": 1,
  "nextCursor": null
}

Search returns undeployed intents only. Recording the first deployment removes an intent from search while keeping its jb, signature, exact launch calls and deployment provenance at GET /v1/intents/:id. That is the contract clients rely on: query Center and Bendystraw concurrently and concatenate, and nothing is listed twice.

owner is what an account page needs: searchIntents({ owner }) beside getProjectsOwnedBy(owner) from Bendystraw is the account's complete project list. publisher is what an integrator's own operations dashboard needs: every intent a server key signed, whoever owns the resulting projects.

Rendering a list and a page

The SDK's mergeSearch(bendystrawRows, intentItems) merges both sources newest first, building each intent row with intentRow(item) and flagging it undeployed: true. intentPath(id) is /intent/<id>. Render that page from decodeDeploymentCall(call) on any one of the intent's deployment calls — the launch is the same logical configuration on every chain — plus the pinned metadata. No chain reads are needed or possible: there is nothing on chain yet.

decodeDeploymentCall returns a typed shell per launch flavor:

Flavor Typed fields
project owner, projectUri, rulesetConfigurations, terminalConfigurations, memo
project-721 owner, projectUri, rulesetConfigurations, terminalConfigurations, memo, salt
omnichain owner, projectUri, rulesetConfigurations, terminalConfigurations, memo, has721
revnet operator, projectUri, stages, description, accountingContexts
homerun-fund owner, projectUri, tokenName, ticker, mustStartAtOrAfter, salt, peerSuckerDeployers
unknown none — render generically, never guess a shape

Keep intents out of Trending and Top: those rankings are volume-based and an intent has no volume.

POST /v1/intents/:id/deploy
Content-Type: application/json

{ "chainIds": [8453] }

Center executes the intent's own signed calls at its own expense. The body is optional. With no body, or with no chainIds, Center queues every chain of the intent that it sponsors and that has no deployment yet. With chainIds, every id must be in the intent, must be one Center sponsors, and must have no deployment; an id whose row failed with no paid bundle is queued again as a fresh attempt, reserved and rated like a first request; a failed row that still carries a bundle, which Relayr may yet execute, and an id with any other row come back as they are. A request that names no chain leaves a failed row as it is. The chains queued by one request must all be from one family.

An intent whose chainIds also name a chain Center does not sponsor, such as Ethereum, is queued for its sponsored chains. The rest is deployed with a relay request.

Status Body Meaning
202 {"deploys":[...]} At least one chain was queued. One row per requested chain
200 {"deploys":[...]} Every requested chain already has a row. The same rows come back
400 bad_request The id is not a UUID, a named chain is not in the intent, is not sponsored or is already deployed, nothing sponsored is left to deploy, or the selected chains span both families
404 not_found No such intent
409 mixed_sender A wallet already deployed a chain of this intent. Deploy the rest from that wallet
429 sponsor_budget The daily sponsorship budget is spent: the shared one, or an MCP caller's own slice of it. Retry-After: 86400
429 sponsor_quota The requester's daily quota is spent. Retry-After: 86400
503 unavailable No sponsor is configured, or sponsorship is paused

The budget is checked before the quota, so a budget refusal costs the requester nothing. The SDK's describeCenterRefusal(error) turns sponsor_budget, sponsor_quota and unavailable into one authored sentence each, plus a generic sentence for any other 429; it returns null for a refusal it does not recognize, such as bad_request or not_found.

Family Chain ids
Mainnet 10 Optimism, 8453 Base, 42161 Arbitrum
Testnet 11155111 Sepolia, 11155420 Optimism Sepolia, 84532 Base Sepolia, 421614 Arbitrum Sepolia

The chains queued by one request must come from one family. Ethereum mainnet (1) is never sponsored: an intent that includes it is queued for its sponsored chains, and Ethereum is deployed with a relay request or from the wallet that deploys every chain.

Cost and quotas

Setting Default
Deploys per requester per day (SPONSOR_DEPLOYS_PER_REQUESTER_PER_DAY) 5
Shared daily budget (SPONSOR_DAILY_BUDGET_WEI) 50000000000000000 wei (0.05 ETH)
The MCP's slice of that day (SPONSOR_MCP_DAILY_BUDGET_WEI) a fifth of the shared daily budget
Reservation per chain maxGas * maxFeePerGas + creationFeeCeiling = 8000000 * 1000000000 + 100000000000000 = 8100000000000000 wei (0.0081 ETH)
Confirmations before a chain counts as confirmed 2

As of 2026-09-21, production raises SPONSOR_DEPLOYS_PER_REQUESTER_PER_DAY to 100 deploys per requester per day; the shared daily budget runs at its default.

A request reserves the full amount for every chain up front. A reservation is released when the chain confirms, and when a chain fails without a bundle having been paid for it; a failed row that did pay a bundle keeps its reservation for the rest of the 24-hour window, because that money may have left. The budget is charged what the sponsor actually spent once the Relayr prepayment settles.

For browser callers the requester is the calling origin and IP; for the MCP tools it is one shared Center-side bucket, so the MCP's daily sponsored deploys draw down one shared SPONSOR_DEPLOYS_PER_REQUESTER_PER_DAY allowance across every MCP caller. Those deploys are also checked against SPONSOR_MCP_DAILY_BUDGET_WEI before the shared budget, so a busy assistant refuses at its own slice rather than spending the day out from under first-party apps.

Polling

Poll GET /v1/intents/:id and read deploys. This is a /v1 route like any other, subject to the same 600-requests-per-minute per-caller limit (429 rate_limit, Retry-After: 60), so poll on an interval, not in a tight loop. A row moves queued to sent to confirmed, or to failed. A confirmed chain also writes its deployments entry, so deployments and deploys converge. A queued or sent row may carry an error while Center keeps retrying it: SPONSOR_UNFUNDED (the sponsor key cannot cover that chain's creation fee yet), SPONSORSHIP_RPC_UNAVAILABLE (a chain could not be read or simulated), RELAYR_INVALID_STATUS (the execution service answered with a status Center would not bind) and RELAYR_TIMEOUT (the execution service has not executed the bundle yet) are all waiting states, not outcomes. Keep polling while status is not failed.

failed is set only on a definitive outcome — a reverted or unverifiable deployment, a refused quote, an exhausted set of attempts, a wallet deployment recorded for the intent before the row had a bundle (mixed sender) — or after 24 hours of waiting: bundle unresolved for a row whose bundle the execution service never resolved, retries exhausted for one that never got that far. An operator reconciles an unresolved bundle by recording its deployment through POST /v1/intents/:id/deployments once the execution service shows the hash. A failed row is terminal for that intent: Center will not retry it and a second POST /v1/intents/:id/deploy returns the same rows, including the failed one. Recovery is a new intent, or the self-paid path for a fresh intent — never a partial self-paid patch over the same one.

The SDK wraps this as ensureDeployed({ client, intent, onStep, pollMs, timeoutMs, signal }), which requests the sponsored deploy, polls on a 4-second default interval until every chain is confirmed, and returns Record<chainId, projectId>. It throws EnsureDeployedError carrying the chainId of the row that failed.

Relay a chain the payer sends

POST /v1/intents/:id/relay
Content-Type: application/json

{ "chainId": 1 }

Center signs that chain's launch and hands the signed request back. Nothing is stored, nothing is queued and no ETH leaves Center. The payer sends one transaction to the forwarder and the project is created with Center's sponsor as its sender, so the chain pairs with every chain Center deploys itself.

{
  "chainId": 1,
  "to": "0x3bA60b60933916a7C87D0860DcEE62a0CE34E3e2",
  "data": "0x47153f82...",
  "value": "100000000000000",
  "gas": "404761",
  "deadline": 1700001800,
  "setup": []
}
Field Meaning
to The canonical ERC2771Forwarder on that chain
data execute(request) carrying the signed forward request
value JBProjects.creationFee() in wei, as a decimal string. The transaction must send exactly this
gas The gas the outer transaction needs: the signed inner gas, the 1/64 the EVM keeps back, and the forwarder's overhead
deadline Unix seconds. The signature is valid for 30 minutes
setup The chain's Safe creations as {to, data, value}, in order, minus any Safe that already exists. Send these first, from any address

A Safe 1.4.1 address depends on the factory, the initializer and the salt, never on the sender, so the payer creates the Safes and Center's signed launch still finds them at the same addresses. The launch itself must stay a forwarded request; sent from any other address it produces different token and sucker salts.

Status Code Meaning
200 — The signed request
400 bad_request The id is not a UUID, chainId is missing or not in the intent, or that chain already has a deployment
400 sponsored_chain Center deploys that chain itself. Use POST /v1/intents/:id/deploy
404 not_found No such intent
409 mixed_sender A wallet already deployed a chain of this intent. Deploy the rest from that wallet
429 relay_limit 30 relay requests per requester per hour. Retry-After: 3600
503 unavailable No sponsor is configured, or sponsorship is paused
503 relay_unavailable Center could not read, simulate or sign for that chain

Sponsored chains are refused so that a visitor and the sponsor lane never hold the same forwarder nonce at once. Two visitors can: both get the nonce the forwarder holds now, the first transaction to land consumes it and the second reverts. Fetch the request again and send it. A request is valid only until its deadline.

Center's sponsor key must hold at least JBProjects.creationFee() on a relayed chain, because both simulations send that value from the sponsor. The fee is never spent there; on Ethereum it is 100000000000000 wei (0.0001 ETH).

When the transaction confirms, record it with POST /v1/intents/:id/deployments as for any chain the payer sends. The verifier accepts the forwarded call: the trace carries the committed calldata plus the appended sender, and the recorded deployment reads forwarded: true.

Self-paid deploy and recording it

Send the intent's exact per-chain calls from one wallet, with JBProjects.creationFee() as the value, then tell Center about each result:

POST /v1/intents/:id/deployments
Content-Type: application/json

{ "chainId": 8453, "projectId": "123", "transactionHash": "0x..." }

Before writing, Center fetches the receipt and the call trace from its own RPC and requires all of the following: a successful transaction with the configured confirmation count; exactly one JBProjects.Create(projectId, owner, caller) event from the canonical JBProjects, and it must be for the claimed projectId specifically, not merely present; and a successful direct or nested CALL whose target matches the signed per-chain commitment and whose calldata is either that commitment verbatim, or that commitment plus exactly one appended 20-byte address when the calling frame's caller is the canonical ERC2771Forwarder (the forwarder appends the signer's address to a forwarded call). Nested matching covers Safe and Relayr execution.

Status Code Meaning
201 — Recorded. The body carries forwarded: true for a call the canonical forwarder made with Center's sponsor as the appended sender, false for one any other sender made
400 bad_request Bad id, bad hash, bad projectId, or a chainId outside the intent
404 not_found No such intent
409 conflict A different deployment is already recorded for that chain
422 deployment_unverified The trace or the event did not match the signed commitment
503 unavailable Deployment verification is not configured

Never mix senders. Recording a chain your own wallet sent closes the deploy and relay routes for the whole intent with 409 mixed_sender, and every chain still queued without a bundle is retired at once with mixed sender, on every chain of the intent, so the sponsor can never launch it later. A chain whose sponsored bundle was already paid is left to finish. Deploy the rest from the same wallet. A chain Center does not sponsor is relayed, not sent from your wallet, whenever Center deploys any other chain of the intent.

Worked examples

Beep: a server key publishes, the merchant owns

Beep creates a terminal for a merchant who has no wallet in hand. Beep's server key is the publisher; the merchant's address is jb.owner and the owner inside the launch calldata. The project is listed and has a page immediately. The first charge runs ensureDeployed at Beep's single write chokepoint, before anything in the review-simulate-send pipeline, because there is no projectId to write against until it resolves.

  • format: beep.biz/terminal.v1
  • publisher: Beep's server key, the only signer, on every intent
  • jb.owner: the merchant
  • Lists: searchIntents({ publisher }) for Beep's own dashboard, searchIntents({ owner }) for the merchant's projects
  • Deploy: sponsored, triggered by the first charge

Homerun: the owner publishes, chains linked by one salt

Homerun creates a FUND across sponsored rollups. The merchant's own external wallet signs, so the merchant is both publisher and jb.owner. The client fixes one random salt and one absolute start before building the per-chain calls, so every chain carries identical arguments and the suckers link.

  • format: homerun.money/fund.v1
  • jb: { app: "homerun", kind: "fund", name, owner, chainIds, tokenName, ticker, salt, mustStartAtOrAfter, projectUri }
  • Chains: sponsored rollups only. A FUND that includes Ethereum mainnet is self-paid
  • Deploy: one Deploy action on the project page, sponsored, no self-paid fallback

Common mistakes

  • Stage 1 with mustStartAtOrAfter: 0. Zero means "start now", and "now" is deploy time, not the time the signer saw. Every later stage boundary shifts with it. Set an absolute timestamp: the moment the intent is made, or an explicitly chosen future time. Stage timestamps are honored exactly as signed; a late deploy does not move them.
  • Deploying some chains yourself and asking Center to sponsor the rest. The salts stop matching and the chains never link into one omnichain project. There is no repair. Ethereum is not an exception: relay it, so its sender is the sponsor like every other chain.
  • Hand-building the signing message. Always take it from POST /v1/intents/message, and always run the two guard checks before signing.
  • Signing without comparing the prepared envelope to the local one. The signature is over whatever Center normalized, not over what you meant.
  • Showing intents in Trending or Top. Those are volume-based; an intent has none.
  • Calling them "intents" in user-facing copy. It invites edit and withdraw requests that do not exist. Say "project", with a "Deploys on first use" label.
  • Treating the publish signature as transaction approval. It authorizes storage and publication of frozen calldata. Deployment is a separate funded transaction.
  • Publishing from a smart account. Center recovers the signer from the signature; a contract signature is refused. Publish from an externally owned account.
  • Expecting a failed sponsored row to retry. It is terminal for that intent. A row that is still queued or sent with an error is the opposite case: Center is retrying it.
  • An HTTPS logoUri. The first-party webclients render ipfs:// only.