bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 75쪽 중 31쪽
RNING: may be absent in the sandbox ABI — verify before submitting
{ name: "initialSupply", type: "uint256" },
{
name: "tokenManagerSettings",
type: "tuple",
components: [
{ name: "rateLimit", type: "uint256" },
{ name: "maxTransferAmount", type: "uint256" },
],
},
],
},
],
outputs: [{ name: "tokenId", type: "bytes32" }],
},
] as const;
// Encode addresses as 20-byte packed bytes.
// Do NOT use encodeAbiParameters, which pads to 32 bytes and is incompatible
// with the contract's address decoding.
const ownerBytes = encodePacked(["address"], [account.address]);
const operatorBytes = encodePacked(["address"], [account.address]);
const sepoliaRegisterHash = await sepoliaWallet.writeContract({
address: CCTS_ADDRESS,
abi: registerAbi,
functionName: "registerCustomToken",
args: [
salt,
SEPOLIA_TOKEN,
{
tokenManagerType: 1, // BURN_MINT
tokenManagerOwner: ownerBytes,
tokenManagerOperator: operatorBytes,
tokenOwner: "0x", // omit for an existing ERC-20
tokenMinter: "0x", // omit for an existing ERC-20
initialSupply: 0n, // see ABI warning above
tokenManagerSettings: {
rateLimit: 0n, // intentionally 0; you set a non-zero value in step 7
maxTransferAmount: 0n, // 0 = no per-transfer cap; set a non-zero value if you need a ceiling
},
},
],
});
await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaRegisterHash });
console.log("Registered on Ethereum Sepolia:", sepoliaRegisterHash);
```
## Step 4: Configure on Base Sepolia
Call `registerCustomToken` on Base Sepolia using the same `deployer` wallet and
the same `salt`. Pass the Base Sepolia token address. The `tokenId` is derived
from `(deployer address, salt)`—not from blockchain state—so it is identical to
the one computed in step 3.
```typescript theme={null}
const baseRegisterHash = await baseWallet.writeContract({
address: CCTS_ADDRESS,
abi: registerAbi,
functionName: "registerCustomToken",
args: [
salt, // same salt as Ethereum Sepolia — required for matching tokenId
BASE_TOKEN, // Base Sepolia token address
{
tokenManagerType: 1,
tokenManagerOwner: ownerBytes,
tokenManagerOperator: operatorBytes,
tokenOwner: "0x",
tokenMinter: "0x",
initialSupply: 0n,
tokenManagerSettings: {
rateLimit: 0n,
maxTransferAmount: 0n, // 0 = no per-transfer cap; set a non-zero value if you need a ceiling
},
},
],
});
await basePublic.waitForTransactionReceipt({ hash: baseRegisterHash });
console.log("Registered on Base Sepolia:", baseRegisterHash);
console.log("tokenId (same on both chains):", tokenId);
```
## Step 5: Retrieve the TokenManager addresses
Call `resolveTokenManager` on each blockchain to get the deployed `TokenManager`
address. You need these addresses to grant the minter role in the next step and
to call `setRateLimit` in step 7.
```typescript theme={null}
const sepoliaTokenManager = await sepoliaPublic.readContract({
address: CCTS_ADDRESS,
abi: serviceAbi,
functionName: "resolveTokenManager",
args: [tokenId],
});
const baseTokenManager = await basePublic.readContract({
address: CCTS_ADDRESS,
abi: serviceAbi,
functionName: "resolveTokenManager",
args: [tokenId],
});
console.log("Sepolia TokenManager:", sepoliaTokenManager);
console.log("Base TokenManager:", baseTokenManager);
```
## Step 6: Grant the minter role
The `TokenManager` burns your token on the source blockchain and mints it on the
destination blockchain. You must grant it the minter role on your ERC-20 on each
blockchain before transfers can succeed.
The exact call depends on your token's access-control interface. For
FiatToken-compatible contracts (such as USDC-style tokens), call
`configureMinter` from the `masterMinter` address. Replace the function name and
ABI with whatever your contract exposes.
```typescript theme={null}
// Example for FiatToken-compatible contracts.
// Replace with the minting authorization method your token exposes.
const fiatTokenMinterAbi = [
{
name: "configureMinter",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "minter", type: "address" },
{ name: "minterAllowedAmount", type: "uint256" },
],
outputs: [{ name: "ok", type: "bool" }],
},
] as const;
const UNLIMITED = 2n ** 256n - 1n;
// Grant minter role on Ethereum Sepolia — caller must be the masterMinter
const sepoliaMinterHash = await sepoliaWallet.writeContract({
address: SEPOLIA_TOKEN,
abi: fiatTokenMinterAbi,
functionName: "configureMinter",
args: [sepoliaTokenManager, UNLIMITED],
});
await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaMinterHash });
// Grant minter role on Base Sepolia — caller must be the masterMinter
const baseMinterHash = await baseWallet.writeContract({
address: BASE_TOKEN,
abi: fiatTokenMinterAbi,
functionName: "configureMinter",
args: [baseTokenManager, UNLIMITED],
});
await basePublic.waitForTransactionReceipt({ hash: baseMinterHash });
console.log("Minter role granted on both chains");
```
If your token uses a different access-control pattern (such as OpenZeppelin
`AccessControl` or a custom owner-managed role), call the equivalent grant
function on each deployment.
## Step 7: Set a non-zero rate limit
Every `TokenManager` deploys with `rateLimit = 0`. A zero rate limit blocks all
outbound and inbound transfers for that token on that blockchain. Call
`setRateLimit` on each `TokenManager` with a non-zero value.
Only the operator can call `setRateLimit`. If you encoded your address as the
operator in `DeploymentParams`, you can call it directly. If the operator field
was left empty (`"0x"`), only the service operator can set the rate
limit—contact Circle to adjust it.
```typescript theme={null}
const tokenManagerAbi = [
{
name: "setRateLimit",
type: "function",
stateMutability: "nonpayable",
inputs: [{ name: "rateLimit", type: "uint256" }],
outputs: [],
},
] as const;
// Replace with an appropriate limit for your token and expected transfer volume.
// This example sets a limit of 1 million tokens assuming 6 decimal places.
const rateLimit = 1_000_000n * 10n ** 6n;
const sepoliaRateLimitHash = await sepoliaWallet.writeContract({
address: sepoliaTokenManager,
abi: tokenManagerAbi,
functionName: "setRateLimit",
args: [rateLimit],
});
await sepoliaPublic.waitForTransactionReceipt({ hash: sepoliaRateLimitHash });
const baseRateLimitHash = await baseWallet.writeContract({
address: baseTokenManager,
abi: tokenManagerAbi,
functionName: "setRateLimit",
args: [rateLimit],
});
await basePublic.waitForTransactionReceipt({ hash: baseRateLimitHash });
console.log("Rate limits set on both chains");
```
## Step 8: Verify the configuration
Call `resolveTokenAddress` on each blockchain to confirm that the `tokenId`
resolves to your token addresses.
```typescript theme={null}
const sepoliaResolved = await sepoliaPublic.readContract({
address: CCTS_ADDRESS,
abi: serviceAbi,
functionName: "resolveTokenAddress",
args: [tokenId],
});
const baseResolved = await basePublic.readContract({
address: CCTS_ADDRESS,
abi: serviceAbi,
functionName: "resolveTokenAddress",
args: [tokenId],
});
console.log("Sepolia resolved token:", sepoliaResolved); // should match SEPOLIA_TOKEN
console.log("Base resolved token:", baseResolved); // should match BASE_TOKEN
```
Both addresses should match the token addresses you configured. Your token is
now configured for crosschain transfers on Ethereum Sepolia and Base Sepolia. To
initiate your first transfer, see
[Transfer EURC from Ethereum to Arc](/cctp/expanded-assets/quickstarts/transfer-eurc-ethereum-to-arc)—the
same pattern applies to your configured token: swap in your `tokenId` and adjust
the source and destination domains as needed.
# Configure an ownerless token
Source: https://developers.circle.com/cctp/expanded-assets/quickstarts/configure-ownerless-token
Set up a crosschain bridge for an existing ERC-20 using CCTP for non-USDC's ownerless token model.
After configuration, anyone can bridge the token between the home blockchain and
any destination you deploy to, without bridge governance or ownership.
## Prerequisites
Before you begin, ensure that you've:
* Deployed the ERC-20 on the home blockchain with the same number of decimals
you intend to use on every destination
* Noted the
[`CrossChainTokenService` address](/cctp/expanded-assets/references/contract-addresses)
for the home blockchain and each destination blockchain
* Obtained access to the Iris fee-quote endpoint
(`POST /v2/quote/cctpx/{tokenId}/{sourceDomain}/{destinationDomain}`)
<Warning>
Configure the token as ownerless on exactly one blockchain—the home blockchain
where the token's supply originates. On every other blockchain, CCTP for
non-USDC deploys a wrapped `CrossChainToken` automatically. Configuring the same
token as ownerless on more than one blockchain will lock supply on each with no
mechanism to release it.
</Warning>
## Step 1. Configure the token on the home blockchain
Call
[`registerOwnerlessToken`](/cctp/expanded-assets/references/contract-reference)
on the local service with the token's address. Submit the transaction and wait
for confirmation, then read the deterministic `tokenId` from the service.
```typescript TypeScript theme={null}
const txHash = await walletClient.writeContract({
address: serviceAddress,
abi: serviceAbi,
functionName: "registerOwnerlessToken",
args: [tokenAddress],
});
await publicClient.waitForTransactionReceipt({ hash: txHash });
const tokenId = await publicClient.readContract({
address: serviceAddress,
abi: serviceAbi,
functionName: "getOwnerlessTokenId",
args: [tokenAddress],
});
```
Save the returned `tokenId`. You will use it in every subsequent step and in
every crosschain transfer. Quote remote deploys with this `tokenId`; using a
placeholder or sentinel in the Iris path causes `QuoteArgsMismatch`.
## Step 2. Quote and deploy the wrapped token on each destination
For each destination, fetch a fee quote from Iris, then call
[`deployRemoteOwnerlessToken`](/cctp/expanded-assets/references/contract-reference)
on the home service. Pass the original token address, the destination domain ID,
and the fee `claim`.
Include a `FORWARD` request with `msgType: "DeployTokenMessage"` so the quote
covers destination deploy gas and Circle can deliver the message. Iris still
expects a positive `amount` string even for deploys.
```typescript TypeScript theme={null}
const quoteRes = await fetch(
`${IRIS_BASE}/v2/quote/cctpx/${tokenId}/${sourceDomain}/${destinationDomain}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
amount: "1",
feeToken: zeroAddress,
requests: [
{
type: "FORWARD",
params: {
msgType: "DeployTokenMessage",
autoExecuteHookData: false,
},
},
],
}),
},
);
if (!quoteRes.ok) throw new Error(await quoteRes.text());
const { signedQuote, feeTotalAmount } = await quoteRes.json();
const claim = {
signedQuote,
refundAddress: zeroAddress, // or your refund address
};
await walletClient.writeContract({
address: serviceAddress,
abi: serviceAbi,
functionName: "deployRemoteOwnerlessToken",
args: [tokenAddress, destinationDomain, claim],
value: BigInt(feeTotalAmount),
});
```
This call sends a CCTP message that, when delivered, creates a remote
`CrossChainToken` wrapper and `TokenManager`. That wrapper is a new protocol
deployment; it is not the same contract as a native copy of your token that may
already exist on the destination.
Omit `FORWARD` only if you will self-relay in the next step.
## Step 3. Complete delivery (forwarded or self-relay)
How the deploy lands depends on the quote:
* **With `FORWARD`:** Circle typically submits `receiveMessage` on the
destination. Poll until
[`resolveTokenAddress`](/cctp/expanded-assets/references/contract-reference)
returns a non-zero address on the destination service.
* **Without `FORWARD`:** Poll Iris
`/v2/messages/{sourceDomain}?transactionHash=…` until the attestation is
complete, then call `receiveMessage` on the destination `MessageTransmitterV2`
yourself. See
[Attestation Verification](/cctp/references/attestation-verification).
## Step 4. Verify the remote contracts
Once the destination has processed the message, look up the wrapped token and
its `TokenManager` on the destination service:
```typescript TypeScript theme={null}
const remoteTokenManager: `0x${string}` =
await destinationPublicClient.readContract({
address: destinationServiceAddress,
abi: serviceAbi,
functionName: "resolveTokenManager",
args: [tokenId],
});
const remoteToken: `0x${string}` = await destinationPublicClient.readContract({
address: destinationServiceAddress,
abi: serviceAbi,
functionName: "resolveTokenAddress",
args: [tokenId],
});
```
## Step 5. Rederive the `tokenId` from the token address
You can rederive the ownerless `tokenId` from a token address on the home
blockchain at any time:
```typescript TypeScript theme={null}
const tokenId: `0x${string}` = await publicClient.readContract({
address: serviceAddress,
abi: serviceAbi,
functionName: "getOwnerlessTokenId",
args: [tokenAddress],
});
```
Before the first outbound transfer, confirm the remote `TokenManager` rate limit
is non-zero; `rateLimit: 0` blocks all outbound transfers. See
[Configure rate limits and transfer caps](/cctp/expanded-assets/howtos/configure-rate-limits-and-caps).
# Deploy a native crosschain token
Source: https://developers.circle.com/cctp/expanded-assets/quickstarts/deploy-native-crosschain-token
Deploy a new CrossChainToken ERC-20 on Ethereum Sepolia and extend it to Base Sepolia using CCTP for non-USDC.
Deploying a native crosschain token creates a brand-new ERC-20 that the protocol
manages across multiple blockchains, with no pre-existing contract required. The
service uses CREATE3 to deploy a `CrossChainToken` ERC-20 and a
`NATIVE_CROSSCHAIN_TOKEN` `TokenManager` at deterministic addresses derived from
your deployment wallet and a salt you choose. This quickstart walks you through
deploying on Ethereum Sepolia and extending the token to Base Sepolia.
<Warning>
Three conditions silently block or break deployment if not met:
* **Same wallet and salt on every blockchain**: The `tokenId` is derived from
`(deployer address, salt)`. Using a different wallet or a different salt on
any blockchain produces a different `tokenId`—no error is thrown, and the
bridge connection is silently broken.
* **Rate limit starts at zero**: Every `TokenManager` deploys with
`rateLimit = 0`, which blocks all transfers. You must call `setRateLimit` with
a non-zero value on each blockchain before any transfer can proceed.
* **`name`, `symbol`, and `decimals` must match on every blockchain**: The
protocol does not enforce consistency. A mismatch breaks user experience and
indexers silently—there is no onchain error.
</Warning>
## Prerequisites
Before you begin, ensure that you've:
* Installed [Node.js v22+](https://nodejs.org/)
* Prepared an Ethereum-compatible testnet wallet whose private key you have
available—this is the deployment wallet, and it must be the same wallet used
on every blockchain
* Funded the wallet with Sepolia ETH and Base Sepolia ETH for gas
* Obtained access to the Iris fee-quote endpoint
(`POST /v2/quote/cctpx/{tokenId}/{sourceDomain}/{destinationDomain}`)
## Step 1: Set up the project
### 1.1. Create the project and install dependencies
```bash theme={null}
mkdir cctpx-deploy
cd cctpx-deploy
npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx --env-file=.env index.ts"
npm install viem
npm install --save-dev @types/node tsx typescript
```
### 1.2. Configure TypeScript (optional)
<Tip>
This step is optional. It helps prevent missing types in your IDE or editor.
</Tip>
Create a `tsconfig.json` file:
```shell theme={null}
npx tsc --init
```
Then, update the `tsconfig.json` file:
```shell theme={null}
cat <<'EOF' > tsconfig.json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"types": ["node"]
}
}
EOF
```
### 1.3. Set environment variables
Open `.env` in your editor and add:
```text theme={null}
PRIVATE_KEY=YOUR_DEPLOYER_PRIVATE_KEY
```
`PRIVATE_KEY` is the private key for the wallet that deploys the token. You must
use this same wallet on every blockchain where you deploy.
<Tip>
Open `.env` in your editor rather than writing values with shell commands, and
add `.env` to your `.gitignore`. This prevents credentials from leaking into
your shell history or version control.
</Tip>
The `npm run start` command loads variables from `.env` using Node.js native
env-file support.
<Warning>
This example uses one or more private keys for local testing. In production,
use a secure key management solution and never expose or share private keys.
</Warning>
## Step 2: Configure clients and define token metadata
Create `index.ts`. Set up public and wallet clients for both blockchains, then
define your token metadata and a `bytes32` salt. The salt, together with your
`deployer` address, determines your token's `tokenId`—it must be identical on
every blockchain.
```typescript theme={null}
import {
createPublicClient,
createWalletClient,
encodePacked,
http,
keccak256,
toBytes,
zeroAddress,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia, sepolia } from "viem/chains";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const sepoliaPublic = createPublicClient({
chain: sepolia,
transport: http(),
});
const sepoliaWallet = createWalletClient({
account,
chain: sepolia,
transport: http(),
});
const basePublic = createPublicClient({
chain: baseSepolia,
transport: http(),
});
const baseWallet = createWalletClient({
account,
chain: baseSepolia,
transport: http(),
});
// CrossChainTokenService — same address on all testnets
const CCTS_ADDRESS = "0x63753E722bd2C2A5DF6EE19C5106662208B81077" as const;
const IRIS_BASE = "https://iris-api-sandbox.circle.com";
// Token metadata — must be identical on every blockchain you deploy to
const TOKEN_NAME = "My Token";
const TOKEN_SYMBOL = "MTKN";
const TOKEN_DECIMALS = 6;
// bytes32 salt — must be the same on every blockchain you deploy to.
// Use any unique bytes32 value; hashing a descriptive string is one approach.
const salt: `0x${string}` = keccak256(toBytes("my-token-v1"));
```
Compute your `tokenId` using the `crossChainTokenId` view call. Pass the raw
salt directly—not `customTokenDeploySalt`, which is only used by
`registerCustomToken`.
```typescript theme={null}
const serviceAbi = [
{
name: "crossChainTokenId",
type: "function",
stateMutability: "pure",
inputs: [
{ name: "deployer", type: "address" },
{ name: "salt", type: "bytes32" },
],
outputs: [{ name: "tokenId", type: "bytes32" }],
},
{
name: "resolveTokenAddress",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "bytes32" }],
outputs: [{ name: "token", type: "address" }],
},
{
name: "resolveTokenManager",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "bytes32" }],
outputs: [{ name: "tokenManager", type: "address" }],
},
] as const;
const tokenId = await sepoliaPublic.readContract({
address: CCTS_ADDRESS,
abi: serviceAbi,
functionName: "crossChainTokenId",
args: [account.address, salt],
});
console.log("Your tokenId:", tokenId);
// Note this value — it will be identical on every blockchain you deploy to.
```
## Step 3: Deploy on Ethereum Sepolia
Call `deployCrossChainToken` on Ethereum Sepolia. No fee quote is needed—this is
a local CREATE3 deploy. The protocol creates the `CrossChainToken` ERC-20 and a
`NATIVE_CROSSCHAIN_TOKEN` `TokenManager` in the same transaction.
<Warning>
**Verify the live ABI before copying this struct.** The sandbox contract's
`DeploymentParams` may omit `initialSupply` while the production contract
includes it, or vice versa. A struct with the wrong number of fields causes
the transac프로젝트로 돌아가기