nFXCF Integration
Integration guide for minting and redeeming nFXCF on supported EVM chains and Solana.
nFXCF is the Plume FalconX CLO Vault. This page is organized around the recommended Actions API path first, then the direct contract path for advanced EVM integrations, plus API and direct onchain paths for Solana.
Outline
- Quick reference
- Supported assets
- EVM Integration:
- Solana Integration
- Errors and edge cases
- Implementation notes
Quick reference
| Field | Value |
|---|---|
| Vault | Plume FalconX CLO Vault |
| Symbol | nFXCF |
| Slug | nest-falconx-clo |
| EVM share token | 0x066d10e240999aea6798b2e2ca0bdac2923cbdff |
| Share decimals | 6 |
| EVM Actions API base URL | https://api.nest.credit/v1/actions |
| Solana API base URL | https://api.nest.credit/v1/solana |
| Solana share mint | 4bpR1mvWgL25NxWBfYKDjiYGfAVttTeo9VJ1LvmbPj9y |
| Solana USDC mint | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
Supported assets
EVM
| Chain | Deposit or redemption asset | nFXCF NestVault | Decimals | Deposit compliance |
|---|---|---|---|---|
Plume98866 | USDC0x2223...a7af | 0x4738...c5e7 | 6 | V2 available |
Plume98866 | pUSD0xdddd...6f3f | 0x74c9...4c05 | 6 | V2 available |
Ethereum1 | USDC0xa0b8...eb48 | 0x4738...c5e7 | 6 | V2 available |
BNB Smart Chain56 | USDT0x55d3...7955 | 0x9c95...8f32 | 18 | V2 available |
Monad143 | USDC0x7547...b603 | 0x4738...c5e7 | 6 | V2 available |
Robinhood Chain4663 | USDG0x5fc5...d168 | 0x7209...0109 | 6 | V2 available |
Solana
| Field | Value |
|---|---|
| nFXCF SPL mint | 4bpR1m...Pj9y |
| OFT program ID | ChEfPd...j1th |
| OFT config key | HTPoqf...qj5x |
| Solana USDC mint | EPjFWd...Dt1v |
| Plume V2 mint composer for nFXCF | 0xc310...cdcb |
| Plume V1 redemption composer for nFXCF | 0xbcad...0648 |
EVM Integration
Deposit
Use the EVM Actions API when your app wants Plume Vaults to handle quote construction, vault selection, Predicate compliance checks, calldata encoding, and transaction simulation for nFXCF. The API returns transactions that your app signs and sends with the user's EVM wallet.
The base URL is:
https://api.nest.credit/v1/actionsAll amounts are raw base-unit integer strings. Do not send decimal UI amounts.
Always set complianceVersion: "v2" explicitly on mint quotes and builds. Defaults vary by vault and chain. A route without an enabled V2 deployment cannot use the V2 examples below; never retry a deposit-on-behalf request with V1.
For aggregators such as LI.FI, use the shared Deposit on behalf section for caller, original depositor, receiver, and proof lifecycle details.
Mint quote
Use the quote route before building transactions. This returns the expected raw share amount, decimals, fee fields, rate fields, and a preview of the transaction steps.
curl -X POST https://api.nest.credit/v1/actions/vaults/nest-falconx-clo/mint/quote \
-H 'content-type: application/json' \
-d '{
"depositAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"depositAmount": "1000000",
"chainId": 98866,
"complianceVersion": "v2"
}'Example response shape:
{
"data": {
"complianceVersion": "v2",
"slug": "nest-falconx-clo",
"shareTokenAddress": "0x066d10e240999aea6798b2e2ca0bdac2923cbdff",
"depositAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"depositAmount": "1000000",
"depositDecimals": 6,
"shareAmount": "...",
"shareDecimals": 6,
"rate": "...",
"rateDecimals": 6,
"feeAmount": "0",
"fees": {
"ratePpm": 0,
"flatAmount": "0",
"maxRatePpm": 0,
"maxFlatAmount": "0"
},
"steps": [
{ "label": "approve", "description": "Approve ComplianceProxy to spend the deposit asset" },
{ "label": "deposit", "description": "Deposit through ComplianceProxy" }
]
}
}steps is a preview. The actual build-tx response may omit approve if the wallet already has sufficient allowance.
Build mint transactions
Use build-tx when the user is ready to deposit. The sender is the account that calls ComplianceProxy and supplies the assets; recipient receives the minted nFXCF shares. This example uses the same wallet for both. For a proxy depositing for a user, explicitly provide that proxy as sender and the original user as onBehalf.
curl -X POST https://api.nest.credit/v1/actions/vaults/nest-falconx-clo/mint/build-tx \
-H 'content-type: application/json' \
-d '{
"depositAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"depositAmount": "1000000",
"chainId": 98866,
"complianceVersion": "v2",
"sender": "0x00000000000000000000000000000000000000ab",
"recipient": "0x00000000000000000000000000000000000000ab",
"skipSimulation": false
}'Require data.complianceVersion === "v2" and submit the returned transactions in order from sender on the requested chain. The builder obtains the attestation internally. Check the deposit transaction's complianceExpiresAt (Unix seconds) and rebuild if it will expire before inclusion; a successful deposit consumes the proof. Keep simulation enabled and enforce your approved minimum output in the executing integration.
Deposit on behalf
Use this flow when an aggregator, router or relayer deposits assets for another user. It applies to every supported EVM NestVault route, including nOPAL and nALPHA. The vault slug, destination chain and deposit asset select the deployment; the identity mapping, API and contract flow stay the same.
LI.FI Earn can use this flow with a proxy address determined at quote time. The policy screens the actual caller and the original depositor in one V2 attestation. A successful compliance check does not replace the vault's live permissions, available liquidity or transaction simulation.
Always send "complianceVersion": "v2" explicitly in Actions mint quotes and builds. The API field is optional, but it is required for this integration. Defaults vary by vault and chain; nREAH1 and nREAH2 have V1 defaults on Plume and Ethereum while supporting explicit V2. Never omit the version or retry an on-behalf flow with V1.
The /v1 prefix in https://api.nest.credit/v1 is the API namespace. It does not select Compliance V1. Direct proof requests select V2 through the /compliance-v2 endpoint.
Resolve the vault route
Read GET https://api.nest.credit/v1/vaults/{vaultSlug} and use its data object. Do not reuse addresses from another vault or chain.
- In
nestVaults[].chainAssets[], match both the destinationchainIdand theassetAddress. The enclosingnestVaultAddressis the asset-specific vault, not the share-token address. - In
complianceDeployments[], require exactly one entry with the samechainId,version: "v2"andenabled: true. Require itsproxyAddressandhookAddress. - Resolve the deposit asset's decimals and encode
depositAmountas a raw integer string. Obtain a current mint quote, fees and limits for the same route. - Stop if a route or deployment is absent, disabled or ambiguous. Metadata shows supported configuration; validate live contract permissions and simulate the complete destination bundle before offering the route.
The complianceVersion and complianceVersionByChain metadata fields describe defaults, not whether explicit V2 is available. Select V2 from complianceDeployments even when the default is V1.
Legacy V1-only routes remain separate. For example, the API registry on October 9, 2026 lists nOPAL on Avalanche and nTBILL on Arbitrum without an enabled V2 deployment. Refresh the registry instead of treating these examples as a permanent list. They cannot use this V2 integration until an enabled deployment is available.
Keep the three identities separate
| Role | API field | Meaning |
|---|---|---|
| Actual caller | sender in the builder; {address} in the compliance URL | The destination EVM account that calls ComplianceProxy, becomes its msg.sender, and supplies the deposit assets. For LI.FI this is the executing proxy, not the user's wallet or the transaction's outer signer. |
| Original depositor | onBehalf | The user represented by the caller. For EVM users, send their address. The compliance response normalizes it to a left-zero-padded bytes32 value. |
| Share receiver | recipient in the builder; receiver in the contract | The account receiving the minted shares. It can be the user or an integration contract that subsequently delivers the shares. It does not substitute for onBehalf. |
The caller must hold the deposit assets and approve ComplianceProxy. A user's approval to the LI.FI proxy does not grant the compliance proxy allowance from that proxy. A LI.FI destination bundle must first make the assets available to the actual caller, approve the compliance proxy if needed, deposit, and deliver the shares to the intended user.
Option A Use the Actions builder
The builder obtains the V2 attestation and encodes the approval and deposit transactions internally. Do not fetch a separate transaction proof for this path.
POST https://api.nest.credit/v1/actions/vaults/{vaultSlug}/mint/build-tx
Content-Type: application/jsonSet the inputs below from the selected route and actual destination identities. buildDepositOnBehalf constructs a same-chain destination deposit: LI.FI handles its own preceding bridge or swap.
import type { Address } from "viem";
async function buildDepositOnBehalf(input: {
vaultSlug: string;
chainId: number;
depositAsset: Address;
depositAmount: bigint;
caller: Address;
depositor: Address;
receiver: Address;
}) {
const response = await fetch(
`https://api.nest.credit/v1/actions/vaults/${encodeURIComponent(input.vaultSlug)}/mint/build-tx`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
chainId: input.chainId,
depositAsset: input.depositAsset,
depositAmount: input.depositAmount.toString(),
sender: input.caller,
recipient: input.receiver,
onBehalf: input.depositor,
complianceVersion: "v2", // Required for every vault; never rely on defaults.
skipSimulation: false,
}),
},
);
if (!response.ok) {
throw new Error(`Mint build failed: ${response.status}`);
}
const { data } = await response.json();
if (data.complianceVersion !== "v2" || data.chainId !== input.chainId) {
throw new Error("Unexpected compliance version or destination chain");
}
const deposit = data.transactions.find(
(transaction: { label: string }) => transaction.label === "deposit",
);
// Choose an inclusion buffer appropriate for your destination chain.
const inclusionBufferSeconds = 60;
if (
!deposit ||
!Number.isFinite(deposit.complianceExpiresAt) ||
deposit.complianceExpiresAt <= Date.now() / 1000 + inclusionBufferSeconds
) {
throw new Error("Rebuild with a fresh V2 proof before execution");
}
return data;
}Before execution, validate the returned asset, amount, deposit target and decoded arguments against the approved route and all three identities. Require the deposit target to match the selected V2 proxyAddress. Execute returned calls in order from sender on chainId. Approval may be absent if allowance is already sufficient. A contract caller must execute the calls itself; do not ask the end user's EOA to send them directly when the proof names a proxy.
The builder simulates by default. A proxy that is not yet deployed or funded may require simulation of the full LI.FI bundle, including its deployment and funding, instead of an isolated mint. If skipSimulation: true is necessary for construction, independently simulate that full bundle before submission; it does not waive compliance or change which account supplies assets.
For amount quotes, use POST /actions/vaults/{vaultSlug}/mint/quote with depositAsset, depositAmount, chainId and complianceVersion: "v2". The quote schema does not accept onBehalf; a quote does not prove eligibility for the caller/depositor pair. Use Option B's purpose=preview for that pair.
Omit destinationChainId for this destination-side deposit. The Actions API's own cross-chain composer route is a separate integration; do not use it to identify an arbitrary LI.FI execution proxy.
Option B Build the contract call directly
Request a V2 proof
GET https://api.nest.credit/v1/user/{actualCaller}/compliance-v2
?vault={vaultSlug}
&chainId={destinationChainId}
&flow=deposit
&onBehalf={originalUser}
&purpose=transactionSend the URL on one line with URL-encoded query values. Use purpose=preview while quoting to check eligibility without receiving a usable proof. Request purpose=transaction immediately before destination execution.
For an arbitrary LI.FI proxy, omit composerAddress. That parameter selects a registered protocol composer and its deployment; it is not a generic caller override.
The response is wrapped in { "data": ... }. Require isCompliant === true, a non-null attestation and non-null complianceData. Check sender, normalized onBehalf, chainId, complianceProxyAddress, hookAddress and payload against the intended request and current deployment.
The signed policy payload is deposit(bytes32) containing the original depositor. It is not the final transaction calldata. The attestation targets the Predicate hook, while the transaction targets the compliance proxy. Forward complianceData unchanged: it already contains abi.encode(Attestation), not a V1 Predicate message or two concatenated proofs.
Encode the destination deposit
The on-behalf ABI has a different argument order from the direct-wallet deposit function:
function depositOnBehalf(
address vault,
address depositAsset,
uint256 assets,
address receiver,
bytes32 depositor,
bytes complianceData
) external returns (uint256 shares);This example receives the route resolved above. It fetches a proof and builds calldata without sending a transaction.
import {
encodeFunctionData,
padHex,
parseAbi,
type Address,
type Hex,
} from "viem";
async function encodeDepositOnBehalf(input: {
vaultSlug: string;
chainId: number;
vault: Address;
depositAsset: Address;
assets: bigint;
complianceProxy: Address;
hook: Address;
caller: Address;
depositor: Address;
receiver: Address;
}) {
const representedUser = padHex(input.depositor, { size: 32 });
const payload = encodeFunctionData({
abi: parseAbi(["function deposit(bytes32)"]),
functionName: "deposit",
args: [representedUser],
});
const query = new URLSearchParams({
vault: input.vaultSlug,
chainId: String(input.chainId),
flow: "deposit",
onBehalf: input.depositor,
purpose: "transaction",
});
const response = await fetch(
`https://api.nest.credit/v1/user/${input.caller}/compliance-v2?${query}`,
);
if (!response.ok)
throw new Error(`Compliance request failed: ${response.status}`);
const { data: proof } = await response.json();
const sameHex = (actual: unknown, expected: Hex) =>
typeof actual === "string" &&
actual.toLowerCase() === expected.toLowerCase();
if (
proof.isCompliant !== true ||
!proof.attestation ||
!proof.complianceData ||
proof.chainId !== input.chainId ||
!sameHex(proof.sender, input.caller) ||
!sameHex(proof.onBehalf, representedUser) ||
!sameHex(proof.complianceProxyAddress, input.complianceProxy) ||
!sameHex(proof.hookAddress, input.hook) ||
!sameHex(proof.payload, payload)
) {
throw new Error("Missing or mismatched V2 authorization");
}
const inclusionBufferSeconds = 60; // Adapt to signing and inclusion latency.
if (
!Number.isFinite(proof.attestation.expiration) ||
proof.attestation.expiration <= Date.now() / 1000 + inclusionBufferSeconds
) {
throw new Error("Rebuild with a fresh V2 proof before execution");
}
const data = encodeFunctionData({
abi: parseAbi([
"function depositOnBehalf(address vault,address depositAsset,uint256 assets,address receiver,bytes32 depositor,bytes complianceData) returns (uint256 shares)",
]),
functionName: "depositOnBehalf",
args: [
input.vault,
input.depositAsset,
input.assets,
input.receiver,
representedUser,
proof.complianceData,
],
});
return { to: input.complianceProxy, data, value: 0n };
}Check the caller's ERC-20 allowance to the selected compliance proxy and include any needed approval before the deposit. Handle tokens that require resetting a nonzero allowance to zero. ComplianceProxy pulls the assets from its msg.sender, approves the asset-specific NestVault internally and mints shares to receiver.
The proof authorizes the caller/depositor pair and operation. It does not bind the amount or receiver and does not enforce a minimum share output. Preserve those constraints in the executing integration and simulate them with the full bundle. Neither deposit nor depositOnBehalf accepts a minimum-share parameter.
Expiry, retries and failures
- Preview: returns eligibility and routing with
attestationandcomplianceDataset tonull. It may warm a short-lived server cache; do not treat that cache as an execution guarantee. - Transaction: returns a one-time proof. Its
attestation.expiration, or the builder transaction'scomplianceExpiresAt, is Unix time in seconds. Leave enough time for signing and inclusion; no fixed lifetime is guaranteed. - Bridge delays: obtain or refresh the proof near destination execution. If the bundle embeds a proof that expires in transit, rebuild the destination call. A failed or expired proof must never trigger a V1 fallback.
- Consumption and retries: successful on-chain authorization consumes the proof. Confirm the original transaction's status before retrying an uncertain submission; obtain fresh authorization for a new execution. Do not share a transaction proof among competing bundles.
- Rejection:
isCompliant: falseis not executable. A missing V2 deployment (404on the compliance endpoint), invalid route, disabled deposits or failed simulation blocks that route. For rate limits (429) and retryable service failures, respectRetry-Afterwhen present and use bounded retries without bypassing compliance.
Before enabling a LI.FI route, simulate the exact proxy's complete destination bundle and verify the minted shares reach the intended receiver with the approved minimum output. Route configuration alone does not establish that this particular bundle can execute.
References
- V2 compliance API
- Mint transaction builder
- Vault integration guides
- Core OpenAPI and Actions OpenAPI
- ComplianceProxy implementation
Redeem
Redeem quote
Use the redeem quote route before submitting a redemption. This quotes the redemption asset amount and validates the selected asset for this vault.
curl -X POST https://api.nest.credit/v1/actions/vaults/nest-falconx-clo/redeem/quote \
-H 'content-type: application/json' \
-d '{
"redemptionAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"shareAmount": "1000000",
"chainId": 98866
}'For this guide, treat the NestVault redemption flow as the supported redemption path. If an integration receives an unsupported route value, surface it as an integration error instead of trying to fall back to a different contract flow.
Build redemption transactions
Use this route to submit a redemption request on the same EVM chain.
curl -X POST https://api.nest.credit/v1/actions/vaults/nest-falconx-clo/redeem/build-tx \
-H 'content-type: application/json' \
-d '{
"redemptionAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"shareAmount": "1000000",
"chainId": 98866,
"user": "0x00000000000000000000000000000000000000ab"
}'NestVault redemptions return:
approve, if the selected NestVault does not already have sufficient share-token allowance.requestRedeem, to submit the asynchronous redemption request.
After the request becomes claimable, use the claim routes below.
Pending, update, and claim
For NestVault-backed redemptions:
curl -X POST https://api.nest.credit/v1/actions/vaults/nest-falconx-clo/claim/pending \
-H 'content-type: application/json' \
-d '{
"redemptionAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"chainId": 98866,
"user": "0x00000000000000000000000000000000000000ab"
}'curl -X POST https://api.nest.credit/v1/actions/vaults/nest-falconx-clo/claim/build-tx \
-H 'content-type: application/json' \
-d '{
"redemptionAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"chainId": 98866,
"user": "0x00000000000000000000000000000000000000ab"
}'claim/build-tx builds a transaction to claim all currently claimable shares for the selected NestVault and user.
The update routes are for reducing an existing pending redemption:
POST /vaults/nest-falconx-clo/update-redeem/pending
POST /vaults/nest-falconx-clo/update-redeem/build-txnewShareAmount is the final pending share amount, not the delta to remove.
Instant redemption
When instant liquidity is available for the selected asset, use:
POST /vaults/nest-falconx-clo/instant-redeem/quote
POST /vaults/nest-falconx-clo/instant-redeem/liquidity
POST /vaults/nest-falconx-clo/instant-redeem/build-txThe instant-redeem request body uses redemptionAsset, shareAmount, chainId, and user. receiver is optional and defaults to user.
Direct onchain
Use direct contracts only when your integration needs lower-level control than the Actions API provides. This path requires you to handle the full flow yourself:
This section covers:
For a direct integration, you handle:
- Validate the selected chain, vault slug, and asset.
- Resolve the enabled Compliance V2 deployment and obtain a fresh transaction attestation for the actual caller.
- Read the correct NestVault for the asset.
- Quote shares or redemption assets.
- Apply any app-level slippage or UX checks.
- Submit ERC-20 approvals.
- Submit deposit, redeem, update, instant-redeem, or claim calls.
- Track asynchronous redemption state.
Main nFXCF contracts
| Contract | Address |
|---|---|
| nFXCF share token | 0x066d...bdff |
| ComplianceProxy V2 (Plume) | 0xb65b...99f1 |
| Predicate V2 hook (Plume) | 0xac00...57ef |
| ComplianceProxy V2 (Ethereum) | 0xb65b...99f1 |
| Predicate V2 hook (Ethereum) | 0xac00...57ef |
| ComplianceProxy V2 (BNB Smart Chain) | 0xb65b...99f1 |
| Predicate V2 hook (BNB Smart Chain) | 0xac00...57ef |
| ComplianceProxy V2 (Monad) | 0xb65b...99f1 |
| Predicate V2 hook (Monad) | 0xac00...57ef |
| ComplianceProxy V2 (Robinhood Chain) | 0xb65b...99f1 |
| Predicate V2 hook (Robinhood Chain) | 0xac00...57ef |
| USDC NestVault (Plume, Ethereum, Monad) | 0x4738...c5e7 |
| pUSD NestVault (Plume) | 0x74c9...4c05 |
| USDT NestVault (BNB Smart Chain) | 0x9c95...8f32 |
| USDG NestVault (Robinhood Chain) | 0x7209...0109 |
For accountants, roles, and other shared protocol contracts, use the Smart Contracts page as the source of truth.
Direct mint outline
V2 uses ComplianceProxy with an opaque complianceData proof. Resolve its proxy and hook from the selected vault's enabled complianceDeployments for the destination chain; do not reuse a shared V1 address.
- Read the NestVault for the selected deposit asset and chain.
- Call
previewDeposit(depositAmount)on that NestVault to estimate shares after fees. Enforce the approved minimum output in your integration;depositanddepositOnBehalfdo not take a minimum-share argument. - Request
/user/{sender}/compliance-v2with the vault slug, chain ID,flow=deposit, andpurpose=transactionimmediately before execution. - The actual caller approves the returned
complianceProxyAddressto spend its deposit asset balance. - Call
ComplianceProxy.deposit(depositAsset, depositAmount, recipient, nestVault, complianceData)from that caller for a direct wallet deposit. For a represented user, includeonBehalfin the proof request and calldepositOnBehalfas described in the Deposit on behalf section above.
Direct wallet deposit outline for Plume:
import { encodeFunctionData, parseAbi, parseUnits, toFunctionSelector } from "viem";
const chainId = 98866;
const sender = "0x00000000000000000000000000000000000000ab";
const recipient = sender;
const depositAsset = "0x222365ef19f7947e5484218551b56bb3965aa7af";
const depositAmount = parseUnits("1", 6);
const nestVault = "0x4738386d69cf5a7ac088da2887fc0df02795c5e7";
// Resolve these from fresh vault metadata for the selected chain.
const complianceProxy = "0xb65b65cff0ca1f3cc12fe58a13110e43dfa999f1";
const hookAddress = "0xac002355fe37c73e9e53be8296d4a75eecc257ef";
const query = new URLSearchParams({
vault: "nest-falconx-clo", chainId: String(chainId),
flow: "deposit", purpose: "transaction",
});
const response = await fetch(
`https://api.nest.credit/v1/user/${sender}/compliance-v2?${query}`,
);
if (!response.ok) throw new Error(`Compliance request failed: ${response.status}`);
const { data: proof } = await response.json();
if (proof.isCompliant !== true || !proof.attestation || !proof.complianceData ||
proof.chainId !== chainId || proof.sender.toLowerCase() !== sender.toLowerCase() ||
proof.onBehalf !== null || proof.payload !== toFunctionSelector("deposit()") ||
proof.complianceProxyAddress.toLowerCase() !== complianceProxy.toLowerCase() ||
proof.hookAddress.toLowerCase() !== hookAddress.toLowerCase()) {
throw new Error("Missing or mismatched V2 authorization");
}
// Choose a buffer that covers signing and inclusion on this chain.
if (!Number.isFinite(proof.attestation.expiration) ||
proof.attestation.expiration <= Math.floor(Date.now() / 1000) + 60) {
throw new Error("Rebuild with a fresh proof before execution");
}
// sender must hold the assets and approve complianceProxy before the deposit.
const data = encodeFunctionData({
abi: parseAbi(["function deposit(address,uint256,address,address,bytes) returns (uint256)"]),
functionName: "deposit",
args: [depositAsset, depositAmount, recipient, nestVault, proof.complianceData],
});
const transaction = { to: complianceProxy, data, value: 0n };
// Simulate and execute from sender on chainId. Do not reuse a spent proof.For LI.FI and other aggregators, follow the vault-independent Deposit on behalf section. It includes the depositOnBehalf ABI, dual screening, expiry handling, and explicit V2 Actions API requests.
Legacy V1 routes require their configured NestVaultPredicateProxy and V1 Predicate message. A V2 attestation cannot be used with that interface. Such routes are outside this V2 deposit-on-behalf integration; do not silently fall back.
Direct redeem outline
For nFXCF redemptions, use the selected NestVault for the redemption asset:
- Approve the NestVault to spend nFXCF shares, if allowance is insufficient.
- Call
NestVault.requestRedeem(shareAmount, user, user). - Wait until shares become claimable.
- Call
NestVault.redeem(claimableShares, user, user)to claim the redemption asset.
If you need to reduce a pending redemption before it is claimable, call NestVault.updateRedeem(newShareAmount, user, user).
Solana Integration
Choose the API path for transaction building, or Direct onchain to construct Circle CCTP and LayerZero OFT instructions locally.
The API supplies prebuilt transactions. Direct onchain integration constructs them locally and funds CCTP event-account rent from the user's wallet. Both use the same downstream settlement flow.
Mints settle asynchronously: after the mint transaction is submitted, the Plume Vaults token is minted to the receiver's Solana wallet, typically in about one minute at default priority. Standard redemptions are also asynchronous and settle according to the vault asset's redemption policy and cadence. After the user submits the Solana redemption request, Plume Vaults keeper automation handles the follow-up redeem and auto-claim steps once liquidity is ready. USDC is returned to the user's Solana wallet automatically; the user does not build or sign a later finish, claim, or finalize transaction. If instant liquidity is available, the Solana redeem endpoint can build an instant-redemption transaction for a fee.
API (recommended)
The API returns a base64-encoded Solana VersionedTransaction. Preserve its existing signatures, add the user and applicable sponsor signatures, and broadcast. The browser example below uses this same API path.
This API section covers:
- Build Solana mint transaction
- Build Solana standard redemption (async) transactions
- Track Solana redemption status
- Build Solana instant redeem transaction
- Sponsored fee payer support
- Browser API pattern
The base URL is:
https://api.nest.credit/v1/solanaSolana transaction builders support both user-paid and sponsored fee-payer transactions. If feePayer is omitted, behavior is unchanged: the user wallet pays Solana transaction fees. If feePayer is provided, it must be a base58 Solana public key and becomes static account key 0, the transaction fee payer.
The user wallet remains the token owner or authority and must still sign. In sponsored mode, the user wallet should pay 0 SOL; the sponsor or relayer signs the fee-payer slot and broadcasts the transaction.
| Flow | Amount field | Type |
|---|---|---|
| Mint | rawAmountUsdc | number |
| Standard redemption (async) request | rawAmountNestToken | string |
| Standard redemption (async) update/cancel | newRawAmountNestToken | string |
| Instant redeem | rawAmountNestToken | number |
Build Solana mint transaction
Use complianceVersion: "v2" for deposits. The V2 mint composer, compliance proxy, and Predicate hook below come from the API.
curl -X POST https://api.nest.credit/v1/solana/nest/mint/build-tx \
-H 'content-type: application/json' \
-d '{
"rawAmountUsdc": 1000000,
"complianceVersion": "v2",
"finality": "standard",
"nestVaultSlug": "nest-falconx-clo",
"receiver": "CFagSTMBFiMaD4YKHr7mMcKTzpi35DqiBKbDA35BvZgr"
}'Response shape:
{
"data": {
"txBase64": "..."
}
}The returned transaction:
- Deposits Solana USDC through CCTP.
- Routes to Plume for the nFXCF mint.
- Delivers nFXCF back to the receiver as an SPL token through LayerZero OFT.
- Always includes the Nest keeper signature and the ephemeral CCTP event-account signature. The keeper funds temporary MessageSent rent, independently of who pays transaction fees.
- Still requires the receiver wallet and applicable sponsor signatures before broadcast.
finality can be standard or fast. fast selects threshold 1000; standard selects 2000. The builder fetches Circle's current fee for that threshold; do not hardcode a fee rate.
Sponsored mint request body:
{
"nestVaultSlug": "nest-falconx-clo",
"rawAmountUsdc": 1000000,
"complianceVersion": "v2",
"receiver": "<USER_SOLANA_PUBKEY>",
"feePayer": "<RELAY_SOLANA_PUBKEY>",
"finality": "fast"
}Build Solana standard redemption (async) transactions
New Solana redemptions currently require complianceVersion: "v1", independently of V2 deposits. The API does not support V2 for new redemption requests or instant redemptions. Use the separate V1 redemption composer for direct redemption calls.
Use standard redemption (async) when a Solana user wants to request a redemption, then later update or cancel the pending request before it is processed. The user's only required Solana action is to sign and broadcast the request or update transaction. After liquidity is ready, Plume Vaults keeper automation completes the redeem and auto-claim flow, and USDC is returned automatically. The user does not submit a separate finish, claim, or finalize transaction.
Sponsored standard redemption (async) request body:
{
"nestVaultSlug": "nest-falconx-clo",
"rawAmountNestToken": "1000000",
"complianceVersion": "v1",
"owner": "<USER_SOLANA_PUBKEY>",
"feePayer": "<RELAY_SOLANA_PUBKEY>"
}Submit that body to POST https://api.nest.credit/v1/solana/nest/async-redeem/request/build-tx.
Sponsored standard redemption (async) cancel/update-to-zero request body:
{
"nestVaultSlug": "nest-falconx-clo",
"newRawAmountNestToken": "0",
"composerAddress": "<ORIGINAL_REDEMPTION_COMPOSER>",
"owner": "<USER_SOLANA_PUBKEY>",
"feePayer": "<RELAY_SOLANA_PUBKEY>"
}Submit that body to POST https://api.nest.credit/v1/solana/nest/async-redeem/update/build-tx. Both standard redemption builders return the same txBase64 response shape as mint and instant redeem.
For update/cancel, use the composerAddress recorded on the original request. A pending request stays with that composer even if the default deployment changes.
Track Solana redemption status
After broadcasting the user's Solana redemption transaction, track the flow with the original Solana transaction signature:
curl https://api.nest.credit/v1/solana/redeem-status/<SOLANA_REDEEM_TX_SIGNATURE>The status response includes the cross-chain stages:
solanaBurn: the user's Solana request transaction.plumeProcessing: delivery and processing on Plume.cctpAttestation: USDC return path attestation.solanaClaim: keeper-driven auto-claim delivery back to the user's Solana wallet.
solanaClaim is a lifecycle stage, not a user action. Apps should show it as automatic settlement progress instead of asking the user to build or sign a separate claim transaction.
Build Solana instant redeem transaction
curl -X POST https://api.nest.credit/v1/solana/nest/redeem/build-tx \
-H 'content-type: application/json' \
-d '{
"rawAmountNestToken": 1000000,
"complianceVersion": "v1",
"nestVaultSlug": "nest-falconx-clo",
"owner": "CFagSTMBFiMaD4YKHr7mMcKTzpi35DqiBKbDA35BvZgr"
}'Response shape:
{
"data": {
"txBase64": "..."
}
}The Solana redeem builder sends nFXCF SPL shares through the OFT path to the nFXCF composer on Plume. The Plume-side flow processes redemption and returns USDC through CCTP.
From the user's perspective, this is the only Solana transaction required for instant redemption. After the signed transaction is broadcast, Plume Vaults' cross-chain infrastructure processes the redeem and returns USDC through CCTP; the user does not submit a separate claim or finalize transaction.
dstEid is deprecated for the Solana redeem endpoint. The destination is Plume mainnet.
Sponsored instant redeem request body:
{
"nestVaultSlug": "nest-falconx-clo",
"rawAmountNestToken": 1000000,
"complianceVersion": "v1",
"owner": "<USER_SOLANA_PUBKEY>",
"feePayer": "<RELAY_SOLANA_PUBKEY>"
}Sponsored fee payer support
After receiving txBase64, decode the Solana VersionedTransaction. If feePayer was provided, assert that transaction.message.staticAccountKeys[0] equals the fee payer public key. The user signs as the owner or receiver, then the relay signs as fee payer and broadcasts.
For sponsored instant redeem and standard redemption (async) flows, the expected required signers are [feePayer, owner]. For sponsored mint, the receiver remains the USDC owner and Plume Vaults share receiver, while the sponsor pays fees. For first-time sponsored mint receivers, the receiver's Plume Vaults share ATA must already exist; the sponsor can create it separately with an idempotent ATA transaction.
Every API-built mint includes keeper and event-account signatures, regardless of fee payer. Changing the instructions, fee payer, or blockhash invalidates all signatures.
| API mint fee payer | Required signers |
|---|---|
| User | User, keeper, event account |
| Sponsor | User, sponsor, keeper, event account |
Browser API pattern
Call the public transaction-builder endpoint directly with standard browser fetch and a Solana wallet adapter. rawAmountUsdcis the raw six-decimal base-unit amount.
import { Connection, PublicKey, VersionedTransaction } from "@solana/web3.js";
export async function mintWithApi(
connection: Connection,
wallet: {
publicKey: PublicKey;
signTransaction(tx: VersionedTransaction): Promise<VersionedTransaction>;
},
) {
const response = await fetch("https://api.nest.credit/v1/solana/nest/mint/build-tx", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nestVaultSlug: "nest-falconx-clo",
rawAmountUsdc: 1_000_000,
complianceVersion: "v2",
receiver: wallet.publicKey.toBase58(),
finality: "standard",
}),
});
if (!response.ok) throw new Error(await response.text());
const { data } = await response.json();
const bytes = Uint8Array.from(atob(data.txBase64), c => c.charCodeAt(0));
const transaction = VersionedTransaction.deserialize(bytes);
// Keep the message and its keeper/event-account signatures intact.
const signed = await wallet.signTransaction(transaction);
return connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
}This example delegates transaction construction to the API, asks the user's wallet to sign, and sends the signed transaction on Solana. It uses user-paid fees; a sponsored transaction also needs the sponsor signature before submission.
Direct onchain
Use direct onchain integration to construct Solana mint and redemption transactions with Circle CCTP and LayerZero OFT. You handle account creation, amount conversion, fee quotes, signing, and submission.
This section covers:
Main Solana accounts
| Mainnet deployment | Address |
|---|---|
| OFT program | ChEfPd...j1th |
| LayerZero endpoint program | 76y77p...jEn6 |
| Circle CCTP v2 TokenMessengerMinter | CCTPV2...UMQe |
| Circle CCTP v2 MessageTransmitter | CCTPV2...PbeC |
| nFXCF share mint | 4bpR1m...Pj9y |
| OFT store / config | HTPoqf...qj5x |
| Token escrow | 32rGLM...gqd3 |
| Plume V2 USDC mint composer | 0xc310...cdcb |
| Plume V2 compliance proxy | 0xb65b...99f1 |
| Plume Predicate V2 hook | 0xac00...57ef |
| Plume V1 redemption composer | 0xbcad...0648 |
| Plume CCTP relayer | 0x7de0...d8be |
| CCTP and OFT address lookup table (current default) | 2Hd1BW...edM6 |
Solana LayerZero endpoint ID: 30168; Plume endpoint ID: 30370. CCTP domains: Solana 5, Plume 22. These are different namespaces from the Plume EVM chain ID 98866. Solana shares use 6 decimals; Plume shares use 6. USDC uses six decimals on both chains.
Program interfaces
Use the LayerZero OFT SDK to read the share token's OFT store, quote fees, and build sends. It derives the endpoint accounts required by each transaction. These links contain TypeScript interfaces; the deployed OFT program's JSON IDL is not currently available here.
Direct mint
For nFXCF minting, the Solana flow is:
- Derive the user's USDC and share ATAs. Create the share ATA if it does not exist.
- Fetch the CCTP fee for the selected finality threshold: 1000 for fast, 2000 for standard.
- Encode the composer's
depositAndSendhook with the net USDC amount. - Build
depositForBurnWithHookwith the Plume CCTP relayer as bothmintRecipientanddestinationCaller. - Compile a versioned transaction with the lookup table and collect the required signatures.
The hook uses the USDC token account for refundTo and the receiver wallet for sendParam.to. Encode Solana addresses as their 32-byte public keys and left-pad EVM addresses to bytes32.
The user pays transaction fees, ATA rent, and temporary CCTP event-account rent. Required signatures are user + event account. The builder signs with the event keypair; the wallet adds the user's signature before submission.
For the USDC burn instruction, save Circle's TokenMessengerMinter v2 IDL and MessageTransmitter v2 IDL in ./idl/ using their original filenames.
import { AnchorProvider, BN, Program, type Idl } from "@coral-xyz/anchor";
import {
ComputeBudgetProgram, Keypair, PublicKey, SystemProgram,
TransactionMessage, VersionedTransaction,
} from "@solana/web3.js";
import {
createAssociatedTokenAccountIdempotentInstruction,
getAssociatedTokenAddressSync, TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { ethers } from "ethers";
// Save the linked Circle v2 IDLs in ./idl/.
import tokenMessengerIdl from "./idl/token_messenger_minter_v2.json";
import messageTransmitterIdl from "./idl/message_transmitter_v2.json";
import { signAndSubmit } from "./submit"; // submission helper below
export async function buildMintLocally(
provider: AnchorProvider,
user: PublicKey, // USDC owner AND share receiver, not a token account
amount = 1_000_000n, // 1 USDC, six decimals
finality: "standard" | "fast" = "standard",
microLamports = 0, // choose a current priority fee before collecting signatures
) {
if (amount <= 0n || amount > BigInt(Number.MAX_SAFE_INTEGER)) {
throw new Error("Amount must be positive and safe for the current fee calculation");
}
const connection = provider.connection;
const tmm = new Program(tokenMessengerIdl as Idl, provider);
const mt = new Program(messageTransmitterIdl as Idl, provider);
const usdc = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const shareMint = new PublicKey("4bpR1mvWgL25NxWBfYKDjiYGfAVttTeo9VJ1LvmbPj9y");
const composer = "0xc310f24fec005872905772ca3b28543bdfcecdcb";
const routeResponse = await fetch(
"https://api.nest.credit/v1/solana/nest/composers/" + composer + "?chainId=98866",
);
if (!routeResponse.ok) throw new Error("Composer route lookup failed");
const { data: route } = await routeResponse.json();
if (route.vaultSlug !== "nest-falconx-clo" || route.chainId !== 98866 ||
route.complianceVersion !== "v2" ||
route.composerAddress?.toLowerCase() !== composer.toLowerCase() ||
!ethers.isAddress(route.relayerAddress)) {
throw new Error("Invalid composer route");
}
const relayer = route.relayerAddress;
const usdcAta = getAssociatedTokenAddressSync(usdc, user);
const shareAta = getAssociatedTokenAddressSync(shareMint, user);
const instructions = [
ComputeBudgetProgram.setComputeUnitLimit({ units: 150_000 }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports }),
];
if (!(await connection.getAccountInfo(shareAta))) {
instructions.push(createAssociatedTokenAccountIdempotentInstruction(
user, shareAta, user, shareMint,
));
}
const minFinalityThreshold = finality === "fast" ? 1000 : 2000;
const response = await fetch("https://iris-api.circle.com/v2/burn/USDC/fees/5/22");
if (!response.ok) throw new Error("Circle fee lookup failed");
const rows: { finalityThreshold: number; minimumFee: number }[] = await response.json();
const row = rows.find(r => r.finalityThreshold === minFinalityThreshold);
if (!row || !Number.isFinite(row.minimumFee) || row.minimumFee < 0) {
throw new Error("Missing or invalid finality fee");
}
// minimumFee is in basis points; round the fee up to a USDC base unit.
const maxFee = BigInt(Math.ceil(row.minimumFee * Number(amount) / 10_000));
if (maxFee >= amount) throw new Error("No net USDC remaining");
const abi = ethers.AbiCoder.defaultAbiCoder();
const callData = abi.encode(
["bytes32", "uint256", "(uint32,bytes32,uint256,uint256,bytes,bytes,bytes)", "address"],
[
ethers.hexlify(usdcAta.toBytes()), // refundTo is a USDC TOKEN ACCOUNT
amount - maxFee,
[30168, ethers.hexlify(user.toBytes()), 0n, 0n, "0x", "0x", "0x"],
composer, // EVM refundAddress
],
);
// sendParam.to above is the receiver WALLET, not shareAta.
const hookData = Buffer.from(ethers.getBytes(ethers.solidityPacked(
["address", "bytes4", "bytes"], [composer, "0xfe030ec4", callData],
)));
const pda = (program: PublicKey, seed: string, ...extra: Buffer[]) =>
PublicKey.findProgramAddressSync([Buffer.from(seed), ...extra], program)[0];
const eventAccount = Keypair.generate();
const relayerBytes32 = new PublicKey(ethers.getBytes(ethers.zeroPadValue(relayer, 32)));
const burn = await tmm.methods.depositForBurnWithHook({
amount: new BN(amount.toString()), destinationDomain: 22,
mintRecipient: relayerBytes32, destinationCaller: relayerBytes32,
maxFee: new BN(maxFee.toString()), minFinalityThreshold, hookData,
}).accounts({
owner: user,
eventRentPayer: user, // user funds the temporary CCTP event account
senderAuthorityPda: pda(tmm.programId, "sender_authority"),
burnTokenAccount: usdcAta,
denylistAccount: pda(tmm.programId, "denylist_account", user.toBuffer()),
messageTransmitter: pda(mt.programId, "message_transmitter"),
tokenMessenger: pda(tmm.programId, "token_messenger"),
remoteTokenMessenger: pda(tmm.programId, "remote_token_messenger", Buffer.from("22")),
tokenMinter: pda(tmm.programId, "token_minter"),
localToken: pda(tmm.programId, "local_token", usdc.toBuffer()),
burnTokenMint: usdc,
messageSentEventData: eventAccount.publicKey,
messageTransmitterProgram: mt.programId,
tokenMessengerMinterProgram: tmm.programId,
tokenProgram: TOKEN_PROGRAM_ID, systemProgram: SystemProgram.programId,
eventAuthority: pda(tmm.programId, "__event_authority"), program: tmm.programId,
}).instruction();
const { value: lookup } = await connection.getAddressLookupTable(
new PublicKey("2Hd1BW1xK5wPZB8zKm9Diu9zvadZJSpK47QyP6CbedM6"),
);
if (!lookup || !lookup.isActive()) throw new Error("Missing/inactive lookup table");
const validity = await connection.getLatestBlockhash("confirmed");
const transaction = new VersionedTransaction(new TransactionMessage({
payerKey: user, recentBlockhash: validity.blockhash,
instructions: [...instructions, burn],
}).compileToV0Message([lookup]));
transaction.sign([eventAccount]);
// signAndSubmit below adds the user's signature, preserving the event signature.
// Save the event account address with the source signature for later rent recovery.
return { transaction, validity, eventAccount: eventAccount.publicKey };
}
export async function mintAndSubmit(provider: AnchorProvider, amount: bigint) {
const { transaction, validity, eventAccount } = await buildMintLocally(
provider, provider.wallet.publicKey, amount,
);
const signature = await signAndSubmit(provider.connection, provider.wallet, transaction, validity);
return { signature, eventAccount }; // retain both for status tracking and rent recovery
}Direct redemption
Send new redemption requests to the V1 redemption composer shown above, not the V2 mint composer. For update/cancel, use the composer that holds the original pending request.
For nFXCF redemptions, the Solana flow is:
- Read the OFT store to obtain the share mint, token escrow, and conversion rate.
- Derive the user's share and USDC ATAs, and check the share balance.
- Convert the share amount from Solana decimals to Plume decimals without discarding OFT dust.
- Encode the redemption command and destination execution options.
- Quote the LayerZero fee with
oft.quote, then build the instruction withoft.send. - Sign and submit the Solana transaction. Keepers handle settlement and return USDC automatically.
Reject amounts not divisible by ld2sdRate rather than silently rounding away OFT dust. For a 9-decimal Solana mint and 6-decimal Plume shares, the rate is 1000; the outer transfer uses Solana units and the inner request/update amount uses Plume units.
| Mode | Outer OFT transfer | Inner SendParam + minMsgValue |
|---|---|---|
| Instant (command 0) | Requested shares to Plume composer | EID 30168, USDC ATA receiver, amount 0, minimum 0, message value 0 |
| Standard request (command 1) | Requested shares to Plume composer | EID 30370, USDC ATA receiver, Plume share amount, minimum 0, message value 0 |
| Update / cancel (command 2) | Zero tokens to Plume composer; message only | EID 30168, version-dependent receiver, final pending Plume share amount (0 cancels), minimum 0, quoted return-message value |
For cancellation/reduction, read version() and pending shares on the active composer. Version 1.2.0 and later keys requests by redeemer wallet + USDC ATA receiver + source EID; older versions use redeemer + source EID and encode the wallet as the update receiver. Unreadable versions are errors, not a signal to fall back. Quote the composer's SHARE_OFT().quoteSend for the returned share difference to the redeemer wallet. Pass that Plume-native fee both as inner minMsgValue and compose-option value. It funds the Plume-to-Solana return; the outer oft.quote returns the separate Solana-native fee for the outbound message.
Select instant or request with a raw share amount; use update with 0n to cancel.
import { Connection, PublicKey, TransactionMessage, VersionedTransaction,
ComputeBudgetProgram } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID, getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction } from "@solana/spl-token";
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { publicKey, createNoopSigner, signerIdentity } from "@metaplex-foundation/umi";
import { mplToolbox, fetchToken } from "@metaplex-foundation/mpl-toolbox";
import { toWeb3JsInstruction } from "@metaplex-foundation/umi-web3js-adapters";
import { oft } from "@layerzerolabs/oft-v2-solana-sdk";
import { Options, addressToBytes32 } from "@layerzerolabs/lz-v2-utilities";
import { ethers } from "ethers";
const tuple = "tuple(uint32 dstEid,bytes32 to,uint256 amountLD,uint256 minAmountLD,bytes extraOptions,bytes composeMsg,bytes oftCmd)";
// New Solana redemptions currently use V1, independently of V2 deposits.
const newRedemptionComposer = "0xbcad976d4d40588aad4a1601e91830c9f0c00648";
const abi = ethers.AbiCoder.defaultAbiCoder();
export async function buildRedeemLocally(
connection: Connection,
owner: PublicKey,
mode: "instant" | "request" | "update",
rawAmount: bigint, // update: FINAL pending amount; 0n cancels, never a delta
plumeRpcUrl = "https://rpc.plume.org",
microLamports = 0,
pendingComposerAddress?: string, // update only: composer recorded on the original request
) {
if (mode === "update" && (!pendingComposerAddress || !ethers.isAddress(pendingComposerAddress))) {
throw new Error("Original redemption composer required for update/cancel");
}
const composerAddress = mode === "update" ? pendingComposerAddress! : newRedemptionComposer;
if (rawAmount < 0n || rawAmount > 18_446_744_073_709_551_615n ||
(mode !== "update" && rawAmount === 0n)) throw new Error("Invalid raw u64 amount");
const umi = createUmi(connection.rpcEndpoint).use(mplToolbox());
const signer = createNoopSigner(publicKey(owner.toBase58()));
umi.use(signerIdentity(signer)); // construction only; does not sign
const programId = publicKey("ChEfPd3RzLeYiRwp1K9evimmaFSd6DV1S4Mv5q5Aj1th");
const storeKey = publicKey("HTPoqfjAwPvtFE2P9cAmgCsxBd8EYEp7CecdgJdLqj5x");
const store = await oft.accounts.fetchOFTStore(umi, storeKey);
const storeAccount = await connection.getAccountInfo(new PublicKey(storeKey));
if (!storeAccount?.owner.equals(new PublicKey(programId)) ||
store.tokenMint !== "4bpR1mvWgL25NxWBfYKDjiYGfAVttTeo9VJ1LvmbPj9y") throw new Error("Unexpected OFT store");
const mint = new PublicKey(store.tokenMint);
const escrow = new PublicKey(store.tokenEscrow); // read state, NOT the owner's ATA
console.log("Vault token escrow:", escrow.toBase58());
const [derivedStore] = PublicKey.findProgramAddressSync(
[Buffer.from("OFT"), escrow.toBuffer()], new PublicKey(programId),
);
if (derivedStore.toBase58() !== storeKey) throw new Error("OFT PDA mismatch");
const solanaDecimals = 6;
const plumeDecimals = 6;
const difference = solanaDecimals - plumeDecimals;
const rate = 10n ** BigInt(Math.max(difference, 0));
if (BigInt(store.ld2sdRate) !== rate) throw new Error("Unexpected OFT conversion rate");
if (rawAmount % rate !== 0n) throw new Error("OFT dust: choose an exactly representable amount");
const plumeAmount = difference >= 0
? rawAmount / rate : rawAmount * 10n ** BigInt(-difference);
const shareAta = getAssociatedTokenAddressSync(mint, owner);
const usdc = new PublicKey("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const usdcAta = getAssociatedTokenAddressSync(usdc, owner);
const balance = (await fetchToken(umi, publicKey(shareAta.toBase58()))).amount;
const transferAmount = mode === "update" ? 0n : rawAmount;
if (balance < transferAmount) throw new Error("Insufficient shares");
let recipient = ethers.hexlify(usdcAta.toBytes()); // USDC token account
let minMsgValue = 0n; // destination PLUME native units, not SOL lamports
if (mode === "update") {
const provider = new ethers.JsonRpcProvider(plumeRpcUrl);
const composer = new ethers.Contract(composerAddress, [
"function version() view returns (string)",
"function SHARE_OFT() view returns (address)",
"function pendingRedeem(bytes32,uint32) view returns (uint256 shares)",
"function pendingRedeem(bytes32,bytes32,uint32) view returns (uint256 shares)",
], provider);
// Fail on an unreadable version; do not silently assume a legacy composer.
const version: string = await composer.version();
const parts = /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?(?:\+.+)?$/.exec(version);
if (!parts) throw new Error("Unrecognized composer version");
const [, major, minor, patch, prerelease] = parts;
const receiverKeyed = Number(major) > 1 || (Number(major) === 1 &&
(Number(minor) > 2 || (Number(minor) === 2 && (Number(patch) > 0 || !prerelease))));
const redeemer = ethers.hexlify(owner.toBytes()); // wallet
const pending: bigint = receiverKeyed
? await composer["pendingRedeem(bytes32,bytes32,uint32)"](redeemer, recipient, 30168)
: await composer["pendingRedeem(bytes32,uint32)"](redeemer, 30168);
if (pending === 0n || plumeAmount > pending) throw new Error("Invalid pending amount");
if (!receiverKeyed) recipient = redeemer; // legacy update uses wallet, >=1.2.0 uses USDC ATA
const returnAmount = pending - plumeAmount;
if (returnAmount > 0n) {
const shareOft = new ethers.Contract(await composer.SHARE_OFT(), [
"function quoteSend(" + tuple + ",bool) view returns (tuple(uint256 nativeFee,uint256 lzTokenFee))",
], provider);
const fee = await shareOft.quoteSend(
[30168, redeemer, returnAmount, 0n, "0x", "0x", "0x"], false,
);
minMsgValue = fee.nativeFee; // funds the Plume -> Solana share return
}
}
const command = mode === "instant" ? 0 : mode === "request" ? 1 : 2;
const inner = [
mode === "request" ? 30370 : 30168,
recipient, mode === "instant" ? 0n : plumeAmount, 0n,
"0x", "0x", abi.encode(["uint8"], [command]),
];
const composeMsg = Buffer.from(ethers.getBytes(abi.encode([tuple, "uint256"], [inner, minMsgValue])));
const options = Options.newOptions().addExecutorComposeOption(
0, mode === "update" ? 350_000 : 100_000, minMsgValue,
);
const params = {
dstEid: 30370, to: Buffer.from(addressToBytes32(composerAddress)),
amountLd: transferAmount, minAmountLd: transferAmount,
options: Buffer.from(options.toHex().slice(2), "hex"), composeMsg,
};
const lookupKey = new PublicKey("2Hd1BW1xK5wPZB8zKm9Diu9zvadZJSpK47QyP6CbedM6");
const { value: lookup } = await connection.getAddressLookupTable(lookupKey);
if (!lookup || !lookup.isActive()) throw new Error("Missing/inactive lookup table");
const rpc = Object.assign(Object.create(Object.getPrototypeOf(umi.rpc)), umi.rpc, { connection });
const accounts = { tokenMint: store.tokenMint, tokenEscrow: store.tokenEscrow };
const { nativeFee } = await oft.quote(
rpc, { payer: signer.publicKey, ...accounts }, { ...params, payInLzToken: false },
{ oft: programId }, [], [publicKey(lookupKey.toBase58())],
);
const send = await oft.send(
rpc, { payer: signer, ...accounts, tokenSource: publicKey(shareAta.toBase58()) },
{ ...params, nativeFee }, { oft: programId, token: publicKey(TOKEN_PROGRAM_ID.toBase58()) },
);
const instructions = [
ComputeBudgetProgram.setComputeUnitLimit({ units: 400_000 }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports }),
];
if (mode !== "update") instructions.push(createAssociatedTokenAccountIdempotentInstruction(
owner, usdcAta, owner, usdc,
));
instructions.push(toWeb3JsInstruction(send.instruction));
const validity = await connection.getLatestBlockhash("confirmed");
const transaction = new VersionedTransaction(new TransactionMessage({
payerKey: owner, recentBlockhash: validity.blockhash, instructions,
}).compileToV0Message([lookup]));
// Have the owner's wallet sign transaction, then simulate and submit below.
// There is NO source-transaction keeper or ephemeral event-account signer.
return { transaction, validity };
}Destination execution options: compose index 0, 100,000 gas for instant/request and 350,000 for update, plus the return value for reduction/cancellation. These combine with onchain enforced options; re-quote before signing. Zero inner minimums do not guarantee a minimum USDC price. Instant liquidity and vault policies still apply.
The user signs the redemption transaction. Pass the returned transaction and blockhash validity to signAndSubmit below.
Submission and settlement
Save this helper as submit.ts. It adds the user's signature, preserves the mint event-account signature, simulates, and submits. Finalize instructions and the blockhash before signing. If either changes, rebuild and collect the signatures again.
import { Connection, PublicKey, type VersionedTransaction,
type BlockhashWithExpiryBlockHeight } from "@solana/web3.js";
export async function signAndSubmit(
connection: Connection,
wallet: {
publicKey: PublicKey;
signTransaction(tx: VersionedTransaction): Promise<VersionedTransaction>;
},
transaction: VersionedTransaction,
validity: BlockhashWithExpiryBlockHeight,
) {
if (!transaction.message.staticAccountKeys[0].equals(wallet.publicKey)) {
throw new Error("Wallet must be the transaction fee payer");
}
if (transaction.message.recentBlockhash !== validity.blockhash) {
throw new Error("Blockhash/validity mismatch");
}
const signed = await wallet.signTransaction(transaction);
const simulation = await connection.simulateTransaction(signed, {
sigVerify: true, commitment: "confirmed",
});
if (simulation.value.err) throw new Error(JSON.stringify(simulation.value));
const signature = await connection.sendRawTransaction(signed.serialize(), {
skipPreflight: false, preflightCommitment: "confirmed",
});
const confirmation = await connection.confirmTransaction({ signature, ...validity }, "confirmed");
if (confirmation.value.err) throw new Error(JSON.stringify(confirmation.value.err));
return signature;
}- Mint: Solana burns USDC and emits the CCTP MessageSent event. After onchain indexing, keepers fetch the Circle attestation, obtain compliance data, and fund the Plume relay and return OFT delivery. No transaction-builder API request is needed to start processing.
- The composer deposits net USDC into the vault, mints shares on Plume, and sends them through LayerZero OFT to the Solana receiver.
- Redemption: OFT delivers shares and the compose message to Plume. Instant redemption uses available liquidity; a standard request waits for the vault's settlement cadence.
- Keepers handle downstream standard redemption settlement and automatic USDC delivery through CCTP back to the user's USDC ATA. An update/cancel returns the removed shares through OFT.
Track destination completion separately using the source signature. These status endpoints observe either construction path; they do not build or sign transactions. Allow for indexing delay.
GET https://api.nest.credit/v1/solana/mint-status/<SOLANA_MINT_SIGNATURE>
GET https://api.nest.credit/v1/solana/redeem-status/<SOLANA_REDEEM_SIGNATURE>
GET https://api.nest.credit/v1/solana/update-status/<SOLANA_UPDATE_SIGNATURE>Recover event-account rent
After Circle's five-day retention window, the user can reclaim the temporary MessageSent account's rent. This is optional and does not affect share delivery. The original rent payer must sign; keepers cannot recover user-funded rent on the user's behalf.
Use the saved eventAccount address and the matchingmessage and attestation from Circle's GET https://iris-api.circle.com/v2/messages/5?transactionHash=<signature>.
import { AnchorProvider, Program, type Idl } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { ethers } from "ethers";
import messageTransmitterIdl from "./idl/message_transmitter_v2.json";
export async function reclaimEventRent(
provider: AnchorProvider,
eventAccount: PublicKey, // saved from buildMintLocally
message: string, // Circle's attested destination message (hex)
attestation: string, // matching Circle attestation (hex)
) {
const program = new Program(messageTransmitterIdl as Idl, provider);
const [messageTransmitter] = PublicKey.findProgramAddressSync(
[Buffer.from("message_transmitter")], program.programId,
);
// Circle requires payee to be the original eventRentPayer.
const transaction = await program.methods.reclaimEventAccount({
destinationMessage: Buffer.from(ethers.getBytes(message)),
attestation: Buffer.from(ethers.getBytes(attestation)),
}).accounts({
payee: provider.wallet.publicKey,
messageTransmitter,
messageSentEventData: eventAccount,
}).transaction();
// Use a legacy transaction without compute-budget instructions to fit the payload.
const validity = await provider.connection.getLatestBlockhash("confirmed");
transaction.feePayer = provider.wallet.publicKey;
transaction.recentBlockhash = validity.blockhash;
const signed = await provider.wallet.signTransaction(transaction);
const signature = await provider.connection.sendRawTransaction(signed.serialize(), {
skipPreflight: false, preflightCommitment: "confirmed",
});
const result = await provider.connection.confirmTransaction({ signature, ...validity }, "confirmed");
if (result.value.err) throw new Error(JSON.stringify(result.value.err));
return signature;
}Errors and edge cases
| Condition | Expected behavior |
|---|---|
| Unsupported vault slug | API returns 400. |
| Unsupported chain ID | EVM Actions API returns 400. |
| Unsupported asset for the selected chain | API returns 400. |
| Non-compliant wallet | EVM mint build returns 400; Solana mint build returns 403. |
| Deposit too small to mint shares | EVM quote/build returns 400. |
| Failed transaction simulation | EVM build-tx returns 400 unless skipSimulation is true. |
| RPC, indexer, or Predicate service issue | API returns 500. |
| Existing allowance is sufficient | approve may be omitted from returned transactions. |
| Solana share amount contains OFT dust | Choose an amount divisible by ld2sdRate. |
| Composer version cannot be read | Stop update/cancel construction until the composer interface is verified. |
| Solana blockhash expired | Rebuild the transaction and collect all required signatures again. |
Implementation notes
- Treat every API amount as a raw integer amount.
- Use the API's returned
depositDecimals,shareDecimals, andredemptionDecimalswhen rendering UI values. - Execute returned EVM transactions in order.
- Never assume
approveis present. Render and execute the returnedtransactions[]. - Do not reuse quotes indefinitely. Re-quote close to the time the user signs.
- For direct contract integrations, prefer the registry and Smart Contracts page over copying addresses from old integration examples.
Solana
- Read the OFT store and verify the lookup table is active before building a transaction. The token escrow is not the user's ATA.
- Direct mints use the user as CCTP event-rent payer. Keep enough SOL for event-account rent, any missing ATAs, and transaction fees.
- Preserve the source signature and event-account address until rent has been recovered. Rent recovery is separate from vault settlement.
- The MessageSent event account is a fresh writable signer. Its rent is separate from transaction fees and ATA rent.
- OFT store seeds are
"OFT" + tokenEscrow; peer seeds are"Peer" + store + destination EID, with the EID encoded as a big-endian u32. - CCTP's
remote_token_messengerPDA uses the destination domain as UTF-8 decimal text ("22"), not a binary u32. The mint example derives the remaining CCTP PDAs and supplies every account explicitly. - USDC and share accounts are writable token accounts, not signers. Their owner signs to authorize the burn or transfer; program-derived accounts do not need external signatures.