Solana

A reviewed devnet payment

Save one order, review exact bytes, approve explicitly and recover the same devnet payment after interruption.

Alpha documentation

Prerequisites

This recipe sends a native SOL payment on devnet through the owning installed runtime. It does not create a wallet, fund an account, approve on page load or fulfil an order. For a first app without wallet effects, complete Your first application first.

Select the devnet composition, configure and verify Conduit's devnet connection, and choose a backed-up devnet payer. Use an intended recipient on the same test network. A small transfer to an unfunded account can encounter account/rent requirements; inspect the actual preflight rather than silently changing the recipient. A self-transfer pays nobody else and is not a merchant-payment proof.

The payer needs the amount plus fees, and token transfers may need associated-account rent. Use only test funds and a wallet appropriate for this alpha. Conduit transport writes, Dispatch submission, Sentinel policy and Latch authority are independent gates.

Save the request before calling the runtime

Your application's durable record must contain operationId, network: 'devnet', sender, recipient and amountLamports. Generate the ID once when the user saves the business action; save and read back the whole record before preparing it. The runtime has its own ledger but is not your order database.

For this native helper, lamports are a positive safe integer, not a decimal SOL value. Convert entered text with the exact amount helpers, validate it against your application's and the selected providers' limits, then save the result. Do not copy a demonstration address into a real product.

Reloads and retries use the same saved record. Another genuine payment gets a new record and identity. Changing sender, amount, network or recipient under an old operation ID is not a retry.

Prepare and preflight

Copy the client module beside reviewed-payment.mjs. This complete module exposes four distinct actions. It snapshots the already-saved terms and prevents overlapping calls from this view; it does not claim a cross-tab or distributed lock.

js
import {createSolanaClient} from './solana-client.mjs';
const client = createSolanaClient(() => window.capp?.solana);

export function createReviewedPayment(savedOrder) {
  if (!savedOrder || savedOrder.network !== 'devnet' ||
      typeof savedOrder.operationId !== 'string' || !savedOrder.operationId.trim() ||
      typeof savedOrder.sender !== 'string' || typeof savedOrder.recipient !== 'string' ||
      !Number.isSafeInteger(savedOrder.amountLamports) || savedOrder.amountLamports <= 0) {
    throw new Error('Load a saved devnet order with an exact positive lamport amount');
  }
  const request = Object.freeze({
    operationId: savedOrder.operationId, network: 'devnet',
    walletPubkey: savedOrder.sender, recipient: savedOrder.recipient,
    amountLamports: savedOrder.amountLamports
  });
  let busy = false;
  async function run(action) {
    if (busy) throw new Error('Wait for the active operation to settle');
    busy = true;
    try { return await action(); }
    finally { busy = false; }
  }
  return Object.freeze({
    operationId: request.operationId,
    review: () => run(async () => {
      const status = await client.call('edge', 'status');
      if (status.mode !== 'devnet' || status.ready !== true) {
        throw new Error('Resolve the selected devnet composition');
      }
      const row = await client.call('edge', 'prepare', request);
      // An existing operation may already be signed, pending or confirmed.
      if (row.state !== 'prepared') return row;
      return client.call('edge', 'preflight', {operationId: request.operationId});
    }),
    approve: () => run(() => client.call('edge', 'authorise', {operationId: request.operationId})),
    send: () => run(() => client.call('edge', 'submit', {operationId: request.operationId})),
    observe: () => run(() => client.call('edge', 'transactionStatus', {operationId: request.operationId}))
  });
}

Create this controller once for the displayed saved order. review() returns preflighted only when the actual checks pass; it may return another existing state or reject. Display the returned sender, recipient, amount, network, fee/account observations and preflight reasons. Inspect preflight.allowed and preflight.transactionInstructionsVerified; do not replace the actual review with a generic green banner.

Address and exact transaction validation remain with the runtime. status.ready does not establish available funds or permission to sign. Preparation records identity before its provider calls, so an interrupted preparation can already have a saved row: repeat preparation only with the original terms.

Keep approval and submission explicit

Bind the controller to separate controls using this sequence. Disable controls while an action is pending and attach error handling; do not chain approval and sending inside the review handler.

User actionController callExpected decision
Review saved paymentreview()Show exact terms and policy results. Only a current preflighted review can proceed to approval.
Approve reviewed paymentapprove()Request the selected wallet's authority. Inspect the returned state; some external authorities can have their own send behaviour.
Send this paymentsend()Submit the recorded authorised bytes. submitted or unknown stays pending, not successful delivery.
Check existing paymentobserve()Reconcile the same operation and signature. This action never creates another payment.

Do not approve from a model response or on view initialization. Keep the busy guard until the original promise settles, even if the user closes the view; removing UI does not cancel an already-running external effect. Observe from the saved ID after reloading. A missing payer key is not permission to select a new signer for that old operation.

Approval can fail before signing, or become uncertain after an external provider has acted. Catching a rejected promise alone does not distinguish those outcomes. Read the existing operation and follow its recorded state. Likewise, Edge can return an observed state with a recoveryError when later evidence or local tracking is incomplete.

SPL-token payments

For a supported original SPL token, obtain a trusted mint/program/decimals observation through Conduit, convert user text with parseAmount(), and persist that exact descriptor and atomic string before calling prepareTokenTransfer(). Configure matching per-mint atomic limits in Sentinel and Latch; allow the actual required programs. A token symbol or a mint named after a familiar asset is not authenticated metadata.

Use preparePayment() instead when paying a merchant's saved request. Preserve its request ID, recipient, asset, amount, reference, memo, network and expiry. Do not rebuild the request from a QR's display text; the standard URI does not encode every saved constraint. The request and verification guide explains the receiving side.

Token-2022, wrapped SOL, arbitrary instructions/swaps and mainnet signing are outside this builder. Use a separately qualified implementation rather than weakening exact-byte checks to accept them.

Expired review versus uncertain submission

An expired, provably never-submitted review can use explicit edge.refresh({operationId}). It retains the old row, creates a linked replacement and requires fresh preflight and approval. Save the returned successor ID with the business record; do not keep directing new actions at the old ID.

A payment request whose terms have expired needs a new merchant decision, not a locally extended expiry. A possibly submitted attempt must instead be observed: a timeout, expired blockhash or missing historical RPC result does not prove that it never landed. A transaction can land after request expiry; rejecting it for fulfilment does not reverse the chain effect.

Verify the first integration

Test a valid small devnet payment, changed terms under the same ID, declined approval, disabled submission, a wrong endpoint network, interrupted preparation and a lost send reply. Reload and check the recorded attempt: the application must not send again to clear a spinner.

Confirm the matching receipt and chain evidence at your required commitment, then record your product outcome separately. Source/runtime fixtures with controlled RPC are useful evidence, not proof of a funded device run. Continue with recovery and receipts and independent merchant verification.

↑ ↓ to navigate · Enter to open · Esc to close. Search stays in your browser.