FACTOR Integration
Integration guide for minting and redeeming FACTOR on supported EVM chains and Solana.
FACTOR is the Plume Factor 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 Factor Vault |
| Symbol | FACTOR |
| Slug | plume-factor-vault |
| EVM share token | 0x1f37620a0e0660db3cdbb8cdbf62d8099f959925 |
| 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 | 6ESVavhfwC4rXHHHZmR6ajg7nLmL6X5UkpZuAcoA7xj7 |
| Solana USDC mint | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v |
Supported assets
EVM
| Chain | Deposit or redemption asset | FACTOR NestVault | Decimals |
|---|---|---|---|
Plume98866 | USDC0x2223...a7af | 0xb195...187f | 6 |
Plume98866 | pUSD0xdddd...6f3f | 0xb91a...e656 | 6 |
Ethereum1 | USDC0xa0b8...eb48 | 0xb195...187f | 6 |
Solana
| Field | Value |
|---|---|
| FACTOR SPL mint | 6ESVav...7xj7 |
| OFT program ID | ChEfPd...j1th |
| OFT config key | DhS3uG...U6RD |
| Solana USDC mint | EPjFWd...Dt1v |
| Plume NestVaultComposer for FACTOR | 0x6fa2...d520 |
EVM Integration
Actions API (recommended)
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 FACTOR. The API returns transactions that your app signs and sends with the user's EVM wallet.
This section covers:
- Mint quote
- Build mint transactions
- Redeem quote
- Build redemption transactions
- Pending, update, and claim
- Instant redemption
The base URL is:
https://api.nest.credit/v1/actionsAll amounts are raw base-unit integer strings. Do not send decimal UI amounts.
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/plume-factor-vault/mint/quote \
-H 'content-type: application/json' \
-d '{
"depositAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"depositAmount": "1000000",
"chainId": 98866
}'Example response shape:
{
"data": {
"slug": "plume-factor-vault",
"shareTokenAddress": "0x1f37620a0e0660db3cdbb8cdbf62d8099f959925",
"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 PredicateProxy to spend the deposit asset" },
{ "label": "deposit", "description": "Deposit through NestVaultPredicateProxy" }
]
}
}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 recipient is the EVM address that signs as depositor and receives the minted FACTOR shares.
curl -X POST https://api.nest.credit/v1/actions/vaults/plume-factor-vault/mint/build-tx \
-H 'content-type: application/json' \
-d '{
"depositAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"depositAmount": "1000000",
"chainId": 98866,
"recipient": "0x00000000000000000000000000000000000000ab"
}'Submit the returned transactions in order. Every returned transaction uses the chain ID from the request. By default, the API simulates the transaction bundle before returning it. User-facing apps should generally keep simulation enabled.
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/plume-factor-vault/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/plume-factor-vault/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/plume-factor-vault/claim/pending \
-H 'content-type: application/json' \
-d '{
"redemptionAsset": "0x222365ef19f7947e5484218551b56bb3965aa7af",
"chainId": 98866,
"user": "0x00000000000000000000000000000000000000ab"
}'curl -X POST https://api.nest.credit/v1/actions/vaults/plume-factor-vault/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/plume-factor-vault/update-redeem/pending
POST /vaults/plume-factor-vault/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/plume-factor-vault/instant-redeem/quote
POST /vaults/plume-factor-vault/instant-redeem/liquidity
POST /vaults/plume-factor-vault/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.
- Check Predicate compliance and obtain a valid Predicate message.
- 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 FACTOR contracts
| Contract | Address |
|---|---|
| FACTOR share token | 0x1f37...9925 |
| NestVaultPredicateProxy | 0xfc0c...9035 |
| USDC NestVault (Plume, Ethereum) | 0xb195...187f |
| pUSD NestVault (Plume) | 0xb91a...e656 |
For accountants, roles, and other shared protocol contracts, use the Smart Contracts page as the source of truth.
Direct mint outline
For FACTOR minting, the direct flow is:
- Read the NestVault for the selected deposit asset and chain.
- Call
previewDeposit(depositAmount)on that NestVault to estimate shares after fees. - Get a Predicate message for the user and chain.
- Approve
NestVaultPredicateProxyto spend the deposit asset. - Call
NestVaultPredicateProxy.deposit(depositAsset, depositAmount, recipient, nestVault, predicateMessage).
Minimal TypeScript outline:
import { encodeFunctionData, parseUnits } from "viem";
const chainId = 98866;
const recipient = "0x00000000000000000000000000000000000000ab";
const depositAsset = "0x222365ef19f7947e5484218551b56bb3965aa7af";
const depositAmount = parseUnits("1", 6);
const nestVault = "0xb195aebf42c93b50e768a97fd3087bb004b4187f";
const predicateProxy = "0xfc0c4222b3a0c9b060c0b959dec62442036b9035";
// 1. previewDeposit on nestVault
// 2. get Predicate message for recipient
// 3. approve depositAsset -> predicateProxy
// 4. encode PredicateProxy deposit call
const data = encodeFunctionData({
abi: NestVaultPredicateProxyAbi,
functionName: "deposit",
args: [depositAsset, depositAmount, recipient, nestVault, predicateMessage],
});The Actions API's mint/build-tx route performs these steps for app integrations and is the preferred reference for expected calldata shape.
Direct redeem outline
For FACTOR redemptions, use the selected NestVault for the redemption asset:
- Approve the NestVault to spend FACTOR 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
curl -X POST https://api.nest.credit/v1/solana/nest/mint/build-tx \
-H 'content-type: application/json' \
-d '{
"rawAmountUsdc": 1000000,
"finality": "standard",
"nestVaultSlug": "plume-factor-vault",
"receiver": "CFagSTMBFiMaD4YKHr7mMcKTzpi35DqiBKbDA35BvZgr"
}'Response shape:
{
"data": {
"txBase64": "..."
}
}The returned transaction:
- Deposits Solana USDC through CCTP.
- Routes to Plume for the FACTOR mint.
- Delivers FACTOR 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": "plume-factor-vault",
"rawAmountUsdc": 1000000,
"receiver": "<USER_SOLANA_PUBKEY>",
"feePayer": "<RELAY_SOLANA_PUBKEY>",
"finality": "fast"
}Build Solana standard redemption (async) transactions
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": "plume-factor-vault",
"rawAmountNestToken": "1000000",
"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": "plume-factor-vault",
"newRawAmountNestToken": "0",
"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.
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,
"nestVaultSlug": "plume-factor-vault",
"owner": "CFagSTMBFiMaD4YKHr7mMcKTzpi35DqiBKbDA35BvZgr"
}'Response shape:
{
"data": {
"txBase64": "..."
}
}The Solana redeem builder sends FACTOR SPL shares through the OFT path to the FACTOR 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": "plume-factor-vault",
"rawAmountNestToken": 1000000,
"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: "plume-factor-vault",
rawAmountUsdc: 1_000_000,
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 |
| FACTOR share mint | 6ESVav...7xj7 |
| OFT store / config | DhS3uG...U6RD |
| Token escrow | DMktDU...q4uN |
| Active Plume USDC composer | 0x6fa2...d520 |
| 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 9 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 FACTOR 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("6ESVavhfwC4rXHHHZmR6ajg7nLmL6X5UkpZuAcoA7xj7");
const composer = "0x6fa27f5447a5ec7b8246d71aab6d7d7c7066d520";
const relayer = "0x7de01896d36Bea9CF072Ac64E41685418941d8bE";
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
For FACTOR 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)";
const composerAddress = "0x6fa27f5447a5ec7b8246d71aab6d7d7c7066d520";
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,
) {
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("DhS3uG7QxsDkwA3oWm4siCazZ3JaQqZBW85cHXdfU6RD");
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 !== "6ESVavhfwC4rXHHHZmR6ajg7nLmL6X5UkpZuAcoA7xj7") 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 = 9;
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.