Skip to content

Residual Unchecked Solana Confirmation Results Create False-Success State in Devnet Routes #2517

Description

@Bayyan16
Field Value
Weakness CWE-252: Unchecked Return Value; CWE-754: Improper Check for Unusual or Exceptional Conditions
Assessed version Unversioned playground commit 8d3e280ab6276679f5f9e0c453734f07845ac53a
Dependency behavior reviewed @solana/web3.js 1.98.4
Affected surface Four unauthenticated, devnet-only or playground API routes; seven transaction-confirmation operations

Executive Summary

Any remote caller who can reach the enabled devnet endpoints can request a test-token mint, mirror-mint creation, or playground faucet transfer to a wallet they choose. The server signs and submits the corresponding transaction. At seven locations, the route awaits Connection.confirmTransaction() but discards its returned SignatureResult; it then advances success-only API or database state without requiring confirmation.value.err === null. If the Solana RPC confirmation resolves with a non-null execution error, the route can report test assets as delivered, consume a claim, or persist a mint address even though the transaction failed on chain. The caller does not steal a signer, bypass Solana authorization, or affect mainnet assets.

The defect is confirmed in the exact application source and the pinned @solana/web3.js dependency. In version 1.98.4, confirmTransaction() returns Promise<RpcResponseAndContext<SignatureResult>>, and SignatureResult.err carries the transaction-processing result. Crucially, the subscription callback can resolve that promise with the returned SignatureResult without converting a non-null err into an exception. An initial status-poll path does reject on value.err, so not every failed transaction reaches the vulnerable continuation. Callers must nevertheless validate the returned value because the API contract permits the resolved-error path.

The narrowest demonstrated impact is false-success and inconsistent off-chain state on devnet: a claim can remain consumed without delivery, an API can advertise an airdrop that did not execute, or devnet_mints can contain a public key for a mint account that was never created. The issue is Low severity because the affected code is explicitly restricted to devnet/playground, the assets have no production value, and no reliable attacker-controlled method of forcing the necessary post-submission execution error was demonstrated.

I reviewed all seven source-to-side-effect sequences at the assessed commit, the locked @solana/web3.js 1.98.4 implementation, preflight and exception controls, the repository's safe confirmation helper, and related GitHub issue and pull-request history. I also ran the offline source verifier and mock control in poc/verify-unchecked-confirmations.mjs. I did not submit a live transaction or induce a failure against a shared devnet deployment.

This report belongs to the same root-control family as GitHub issue #2435 and its merged fix #2436. It does not re-report the faucet and auto-fund operations fixed there. It covers four different routes and seven operations that remain unchecked at the assessed HEAD. Maintainers may reasonably consolidate this residual coverage under the existing issue family.

Background

The affected routes expose server-funded development workflows. They accept public request data, validate addresses and rate limits, and use DEVNET_MINT_AUTHORITY_KEYPAIR to pay fees or authorize test-token mints. No user session or application-level authentication check appears in these four handlers. Public faucet access is intentional and is not itself the reported weakness. The relevant trust boundary is the transition from a Solana execution result to authoritative application state.

The intended invariant is simple:

confirmation.value.err === null
    -> on-chain execution succeeded at the requested commitment
    -> success-only application state may advance

confirmation.value.err !== null
    -> on-chain execution failed
    -> do not persist or report transaction success

The application declares @solana/web3.js as ^1.98.4 in app/package.json:26, and pnpm-lock.yaml:52-54 resolves 1.98.4. In that release, Connection.confirmTransaction() returns a response whose value is a SignatureResult, defined with err: TransactionError | null. The implementation's signature-subscription callback resolves with { context, value: result } regardless of whether result.err is null. By contrast, the initial getSignatureStatus() check rejects when it observes an error. This split behavior is why surrounding try/catch blocks are useful but insufficient: depending on which confirmation path wins, an execution failure may throw or may be returned as data.

The official method documentation is available in the Connection.confirmTransaction() API. In the exact 1.98.4 source, SignatureResult defines err: TransactionError | null. The implementation of confirmTransaction() and its shared confirmation promise then shows the unchecked subscription resolution and the contrasting status-poll rejection. The application independently encodes the same rule in app/lib/transaction-confirmation.ts:17-44: assertSuccessfulConfirmation() accepts only an explicit value.err === null and throws for an execution error or an incomplete response.

Two corrected routes provide strong local controls:

  • app/app/api/faucet/route.ts:193-199 validates the primary faucet's SOL confirmation before leaving its fallback loop.
  • app/app/api/auto-fund/route.ts:212-221 validates the auto-fund USDC mint before setting results.usdc_minted.

The assessed source is an unversioned playground commit. I did not establish a mapping from these residual call sites to a tagged public release, so the only confirmed affected version in this report is commit 8d3e280ab6276679f5f9e0c453734f07845ac53a. No fixed release for these residual call sites was identified.

Vulnerability Details

Root Cause

The affected handlers treat promise fulfillment as proof of successful transaction execution, even though the dependency's contract separates those two facts. confirmTransaction() may fulfill with a SignatureResult whose err field records an on-chain execution failure. Because the handlers discard that result, their surrounding try/catch blocks enforce transport-level completion but not execution-level success. Success flags, claim lifecycle decisions, API responses, and database writes are therefore coupled to the absence of a thrown exception rather than to the explicit value.err === null postcondition. The repository already has a shared fail-closed assertion for this contract, but these seven direct call sites do not use it.

Existing Counter-Controls

The recurring vulnerable sequence is:

const signature = await connection.sendRawTransaction(serializedTransaction);
await connection.confirmTransaction(signature, "confirmed");

// Persists or returns success without examining confirmation.value.err.

The handlers generally have surrounding exception handling. That catches invalid input, signer errors, RPC transport failures, timeouts, many preflight failures, and confirmation paths that reject. The playground USDC submission also explicitly sets skipPreflight: false. These controls reduce the frequency of the defect, but they do not establish the required invariant after a signature is returned. Preflight evaluates a simulation against a particular observed state; account state, blockhash lifetime, program behavior, or other execution conditions can differ by the time the transaction is processed. More decisively, the dependency itself permits a resolved SignatureResult containing err.

Six of the seven sites use the deprecated signature-only confirmation overload. That is a separate reliability concern because it uses a timeout rather than the transaction's known blockhash lifetime. The playground USDC path already uses the blockheight-aware strategy. Neither strategy automatically authorizes the application to ignore value.err.

Seven confirmed source-to-side-effect sequences

# Operation and decisive lines State advanced after an unchecked result Practical consequence
1 app/app/api/devnet-airdrop/route.ts, resolveServerOwnedDevnetMint(), lines 369-397 Confirmation at line 371 is followed by a devnet_mints upsert A fallback mirror address may be recorded even though its mint account was not created
2 app/app/api/devnet-airdrop/route.ts, POST(), lines 755-817 Confirmation at line 760 is followed by mintSucceeded = true at line 791 and a success body at lines 810-817 The reserved 24-hour claim is retained and the API reports an amount although no tokens were minted
3 app/app/api/devnet-mint-token/route.ts, existing-mint branch, lines 310-325 Confirmation at line 313 is followed by mintSucceeded = true and an airdropTokens response The response represents the optional airdrop as successful; the thrown-error branch would instead omit the airdrop fields
4 app/app/api/devnet-mint-token/route.ts, new-mint branch, lines 395-418 and 467-478 Confirmation at line 402 is followed by a devnet_mints insert and a status: "created" response A nonexistent mint can become the stored mapping and be returned to later callers
5 app/app/api/devnet-mirror-mint/route.ts, POST(), lines 359-413 Confirmation at line 362 is followed by a best-effort upsert/re-select and a success response With no pre-existing canonical row, the route may persist or return an address whose mint creation failed
6 app/app/api/playground/faucet/route.ts, USDC branch, lines 230-256 and 286-294 Blockheight-aware confirmation at lines 235-238 is followed by recordClaim() and funded: true The durable gate reserved at line 154 is not released, the in-memory claim is recorded, and the caller is told that 10,000 Sim-USDC was delivered
7 app/app/api/playground/faucet/route.ts, SOL best-effort branch, lines 263-291 Confirmation at line 271 is followed by solAirdropped = true and termination of the RPC fallback loop The response advertises SOL success and suppresses another endpoint attempt even though the airdrop failed

The first database-integrity path is particularly direct:

const createSig = await connection.sendRawTransaction(signedCreateTx.serialize());
await connection.confirmTransaction(createSig, "confirmed");

const newDevnetMint = mintKeypair.publicKey.toBase58();
await supabase.from("devnet_mints").upsert({
  mainnet_ca: mainnetCa,
  devnet_mint: newDevnetMint,
  // ...
});

The generated public key exists locally regardless of transaction execution. It becomes a valid on-chain mint only if the createAccount and initializeMint instructions succeed. The unchecked confirmation can therefore promote a local key into a canonical database identifier without proving that the corresponding account exists.

The claim-retention path in devnet-airdrop is similarly explicit:

await connection.confirmTransaction(txSig, "confirmed");
return txSig;
// ...
mintSucceeded = true;

The finally block releases the reserved claim only when mintSucceeded remains false. A resolved confirmation error does not enter the catch, so the flag is set and the release is skipped.

History and duplicate-family context

The project has already recognized this pattern. Issue #939 reported false success in useReclaimSlabRent.ts, and merged PR #940 added value.err checks there and in useCreateMarket.ts and the auto-fund SOL path. Later, issue #2435 documented three additional operations in the primary faucet and auto-fund routes; merged PR #2436 introduced the shared fail-closed helper and fixed those exact operations.

Issue #2435 explicitly stated that other confirmTransaction() call sites were not included unless separately reproduced and validated. The seven operations here are those remaining call sites, not the operations fixed by #2436. Nevertheless, they share the identical missing-result-validation control. The most transparent disposition is therefore a confirmed residual of an existing weakness family, with consolidation left to maintainer triage. A report that claimed this was an unrelated vulnerability would overstate novelty.

The repository snapshot supplied for assessment is not a tagged release. Full first-introduction and release-boundary claims cannot be made from the available release history, so this report does not guess them.

Exploitability Analysis

Established primitive

The source trace and offline control establish this exact primitive:

server submits a devnet transaction
  -> confirmTransaction() resolves with value.err != null
  -> affected route does not inspect the value
  -> catch block is not entered
  -> route advances a success-only flag, response, claim, or database write

This is a transaction-result integrity failure. It is not proof that a remote caller can deterministically manufacture the required Solana execution result on demand.

Reachability and attacker position

The four handlers require NETWORK === "devnet", valid Solana public keys, configured server signing material, and their applicable rate-limit or allowlist/database checks. They do not require an authenticated user session. A remote caller can choose the destination wallet, and the mirror routes also accept a mainnet token address used for metadata or mapping selection. The server controls and authorizes the transaction itself.

To reach the vulnerable branch, submission must return a signature and confirmation must resolve through a path that carries a non-null value.err. An ordinary RPC rejection, timeout, failed preflight, malformed signature, missing signer, or exception before submission is already handled and does not demonstrate this finding. The locked dependency's WebSocket resolution path establishes that the necessary result is representable and can be returned. The source review did not, however, establish a reliable input by which a caller can force it for every route.

Impact calibration

The strongest source-supported outcomes are:

  • Retry denial: devnet-airdrop and playground USDC claims can remain reserved after nondelivery. The applicable window is 24 hours for devnet-airdrop and one hour for the playground faucet.
  • Invalid mapping: mirror creation can persist a generated address for an account that does not exist. Later consumers may resolve that row until an operator removes or replaces it. This is conditional on the database write succeeding and no valid canonical row winning a concurrent upsert.
  • False API state: responses can include funded: true, sol_airdropped: true, airdropTokens, or status: "created" even though the corresponding execution failed.
  • Suppressed recovery: a success flag can prevent claim release or a failed SOL confirmation can stop the RPC fallback loop.

No production-value loss, unauthorized mainnet mint, signature forgery, cross-user data disclosure, privilege escalation, or core protocol accounting failure was established. The devnet-mint-token source also states that the current frontend does not call that route, reducing the demonstrated deployment impact of two sites. These constraints support Low severity.

Positive and negative controls

  • A confirmation with value.err === null is the positive control: the success transition is allowed.
  • The repository's assertSuccessfulConfirmation() rejects the same non-null value.err accepted by the vulnerable continuation. This isolates the missing check rather than an RPC transport issue.
  • The corrected primary faucet and auto-fund routes perform the check before their success flags. They show that the project considers resolution alone insufficient.
  • If confirmTransaction() rejects, the existing catches run; that negative control confirms the problem is specifically the resolved-error form.
  • If preflight rejects submission, no confirmation-to-success transition occurs. Preflight therefore reduces exposure but does not replace outcome validation.

Submission and duplicate risk

The defect is technically strong, but novelty is the principal acceptance risk. A maintainer may close this report as covered by the root cause in #2435 even though #2435 scoped its concrete fix to other operations. For efficient triage, this report should be treated as residual coverage after #2436, not as a new vulnerability class. The recommended maintainer action is a repository-wide completion pass using the existing helper.

Proof of Concept

Artifact purpose and scope

The PoC materials supplied with this report contain two review artifacts, both embedded in full below:

  • verify-unchecked-confirmations.mjs is a dependency-free, read-only source verifier plus a small in-memory control-flow demonstration.
  • README.md gives the same portable procedure, expected interpretation, and limitations in a standalone form.

The verifier is deliberately offline. It does not contact Solana, start Next.js, call an API route, load a wallet or server secret, install application dependencies, or access Supabase. Its purpose is narrower: verify that each documented confirmation call still precedes its success-only side effect in the pinned source, then demonstrate how a normally fulfilled promise carrying value.err !== null differs from a fail-closed result check.

Prerequisites and report-relative execution

Use Node.js 18 or newer and a checkout of dcccrypto/percolator-launch at the assessed commit. From the directory containing this report, the following commands create a sibling target/ checkout at the exact revision and run the verifier:

git clone --branch playground https://github.com/dcccrypto/percolator-launch.git target
git -C target checkout 8d3e280ab6276679f5f9e0c453734f07845ac53a
node poc/verify-unchecked-confirmations.mjs target

No npm, pnpm, Solana CLI, RPC URL, private key, database, or application build is required. If the pinned source is already available, pass its checkout-directory name in place of target; the supplied argument must contain the repository's app/ directory.

Safety and transaction boundary

The script only reads four TypeScript files under the supplied checkout and mutates JavaScript objects in its own process. It neither opens a database transaction nor submits a Solana transaction, so there is no chain state or application state to roll back. The mock signature and slot are inert labels, not values submitted to an RPC service.

Seven source assertions

For each item below, the verifier requires the named source fragments to appear in order in the relevant route file. A missing or reordered fragment causes a nonzero exit rather than a successful PoC result:

  1. devnet-airdrop mirror creation: raw transaction submission, discarded confirmation, then devnet_mints upsert.
  2. devnet-airdrop token delivery: raw transaction submission, discarded confirmation, then mintSucceeded = true.
  3. devnet-mint-token existing-mint delivery: raw transaction submission, discarded confirmation, mintSucceeded = true, then the already_exists response.
  4. devnet-mint-token new-mint creation: raw transaction submission, discarded confirmation, then devnet_mints insert.
  5. devnet-mirror-mint creation: raw transaction submission, discarded confirmation, then devnet_mints upsert.
  6. Playground USDC funding: raw transaction submission, discarded blockheight-aware confirmation, claim recording, then funded: true.
  7. Playground SOL funding: airdrop request, discarded confirmation, then solAirdropped = true.

These ordered substring assertions are intentionally simple and reviewable; they are not an AST-based proof that would remain valid after arbitrary refactoring. The source-to-side-effect analysis and exact line citations in this report are the corresponding manual review evidence.

Mock result, vulnerable flow, and safe negative control

After the source checks, the script uses this in-memory response shape:

{
  context: { slot: 123 },
  value: { err: { InstructionError: [0, "Custom"] } },
}

The error object is synthetic and was not captured from a live RPC call. The vulnerable control awaits a mock function that fulfills with this object, discards the value, and then sets successCommitted = true. This models the decisive JavaScript behavior at the seven call sites: fulfillment does not enter catch merely because the returned data contains a non-null err field.

The safe negative control passes the same object through a local fail-closed assertion. It accepts only an explicit value.err === null, so it throws before setting the success flag. This small assertion mirrors the relevant contract of the repository's helper; the script does not import or execute the application helper itself.

Actually observed output

I executed the command against commit 8d3e280ab6276679f5f9e0c453734f07845ac53a. The process exited with status 0 and produced exactly:

[source confirmed] devnet-airdrop mirror creation persists after unchecked confirmation
[source confirmed] devnet-airdrop token delivery marks success after unchecked confirmation
[source confirmed] existing-mint airdrop returns success after unchecked confirmation
[source confirmed] new mint is inserted after unchecked confirmation
[source confirmed] mirror mapping is upserted after unchecked confirmation
[source confirmed] playground USDC faucet records claim after unchecked confirmation
[source confirmed] playground SOL faucet marks airdrop success after unchecked confirmation
[safe control rejected] confirmed but failed on-chain: {"InstructionError":[0,"Custom"]}
[mock confirmed] vulnerable successCommitted=true; safe flow rejected before commit
PoC completed: all unchecked confirmation paths and mock semantics were confirmed.

The seven [source confirmed] lines mean that all documented ordered sequences were present in the pinned source. The final two control lines mean that the fulfilled-error mock advanced the unchecked flow while the explicit result check rejected the same object before its success transition. They do not mean that any live route, RPC, transaction, claim, or database record was exercised.

Dependency-semantic and route-level limitations

The verifier neither imports @solana/web3.js nor executes its Connection.confirmTransaction() implementation. The proposition that version 1.98.4 can fulfill the confirmation promise from a signature-subscription callback with a non-null SignatureResult.err is grounded separately in the pinned dependency source cited in the Background section. The in-memory mock establishes only the application-level consequence when a promise fulfills with that documented response shape. It does not measure how often that dependency path wins over the initial status poll, which rejects on an observed execution error.

This PoC also does not invoke middleware or route guards, satisfy an allowlist, use the configured mint authority, reserve or consume a claim, submit a transaction, create a mint, exercise RPC fallback, or write devnet_mints. It therefore does not prove a reliable remote trigger, a particular public deployment configuration, or a live Solana exploit. Those limitations are why exploitability remains conditional and severity remains Low.

A route-level regression suite can build on the demonstrated response shape by mocking confirmTransaction() and asserting that a failed execution causes no mint insert or upsert, no success flag or airdrop amount, policy-appropriate claim release, and continued fallback for a retryable SOL airdrop failure.

Cleanup

The verifier itself requires no cleanup because it is read-only and all mock state ends with the Node.js process. The optional target/ checkout is ordinary reviewer-owned source material and may be retained for independent inspection or removed after review.

Embedded PoC Artifacts

To make this report self-contained for owner/maintainer review, the two supplied PoC artifacts are reproduced in full below. Their safety boundary, observed output, interpretation, and limitations are preserved. The README heading levels are adjusted only to fit the hierarchy of this combined report.

Artifact 1 — README.md

Offline PoC: unchecked Solana confirmation results

This directory contains a safe, read-only verifier for the residual unchecked confirmTransaction() call sites described in the parent report. It verifies source ordering and demonstrates the application-level consequence of discarding a fulfilled response whose value.err is non-null. It is not a live Solana or route-level exploit.

Prerequisites
  • Node.js 18 or newer
  • A checkout of dcccrypto/percolator-launch at commit 8d3e280ab6276679f5f9e0c453734f07845ac53a
  • No application dependency installation, Solana CLI, RPC endpoint, wallet, private key, or database
Report-relative execution

From the directory containing the parent report, create a sibling target/ checkout at the assessed revision and run:

git clone --branch playground https://github.com/dcccrypto/percolator-launch.git target
git -C target checkout 8d3e280ab6276679f5f9e0c453734f07845ac53a
node poc/verify-unchecked-confirmations.mjs target

If the pinned source is already available, pass its checkout-directory name in place of target. The supplied directory must contain the repository's app/ directory.

Safety boundary

The verifier reads source files and mutates only in-memory objects inside its own Node.js process. It does not start the application, contact Solana, submit or confirm a transaction, invoke a route, load credentials, access Supabase, reserve a claim, or modify the checkout. There is no blockchain or database transaction to roll back.

Seven source assertions

The script reads four route files and requires these fragments to occur in order:

  1. devnet-airdrop mirror creation: raw transaction submission, discarded confirmation, then database upsert.
  2. devnet-airdrop token delivery: raw transaction submission, discarded confirmation, then mintSucceeded = true.
  3. devnet-mint-token existing-mint delivery: raw transaction submission, discarded confirmation, mintSucceeded = true, then the already_exists response.
  4. devnet-mint-token new-mint creation: raw transaction submission, discarded confirmation, then database insert.
  5. devnet-mirror-mint creation: raw transaction submission, discarded confirmation, then database upsert.
  6. Playground USDC funding: raw transaction submission, discarded confirmation, claim recording, then funded: true.
  7. Playground SOL funding: airdrop request, discarded confirmation, then solAirdropped = true.

A missing or reordered fragment causes the verifier to fail. These are transparent ordered-substring assertions, not an AST analysis that is expected to survive arbitrary refactoring.

Mock and safe negative control

After the source checks, the verifier supplies this synthetic, in-memory response to two control flows:

{
  context: { slot: 123 },
  value: { err: { InstructionError: [0, "Custom"] } },
}

The vulnerable flow awaits a promise fulfilled with the object, discards the response, and commits success. The safe negative control checks for an explicit value.err === null and rejects the same object before commit. The object was not captured from a live RPC call, and the local assertion is a model of the relevant fail-closed contract rather than an import of the repository helper.

Actually observed output

The verifier was executed against the assessed commit and exited with status 0. Its exact output was:

[source confirmed] devnet-airdrop mirror creation persists after unchecked confirmation
[source confirmed] devnet-airdrop token delivery marks success after unchecked confirmation
[source confirmed] existing-mint airdrop returns success after unchecked confirmation
[source confirmed] new mint is inserted after unchecked confirmation
[source confirmed] mirror mapping is upserted after unchecked confirmation
[source confirmed] playground USDC faucet records claim after unchecked confirmation
[source confirmed] playground SOL faucet marks airdrop success after unchecked confirmation
[safe control rejected] confirmed but failed on-chain: {"InstructionError":[0,"Custom"]}
[mock confirmed] vulnerable successCommitted=true; safe flow rejected before commit
PoC completed: all unchecked confirmation paths and mock semantics were confirmed.

The source lines confirm that the seven documented sequences were present in the pinned files. The control lines show that the fulfilled-error mock advanced the unchecked flow while an explicit result check rejected the same object. They do not show that a route or live transaction was exercised.

Interpretation and limitations

The verifier confirms two narrow propositions: the seven success transitions follow discarded confirmation results in the assessed source, and ordinary JavaScript promise fulfillment does not trigger catch merely because the returned object contains value.err.

The verifier does not import @solana/web3.js or execute Connection.confirmTransaction(). The dependency-semantic proposition is established separately in the parent report by reviewing version 1.98.4: a signature-subscription callback can fulfill with a non-null SignatureResult.err, while the initial status-poll path rejects on an observed error. This PoC does not determine which path wins or how often the fulfilled-error form occurs.

It also does not exercise route middleware, allowlists, configured authorities, devnet RPCs, claims, fallback behavior, or database writes. It does not establish a reliable caller-controlled trigger, a public deployment configuration, or a live Solana exploit. These limitations preserve the report's conditional exploitability and Low severity.

Cleanup

The verifier itself needs no cleanup because it is read-only and its mock state ends with the process. The optional target/ checkout may be retained for review or removed afterward.

Artifact 2 — verify-unchecked-confirmations.mjs

The complete read-only verifier source used for the offline validation is embedded below.

#!/usr/bin/env node

/**
 * Safe PoC for unchecked Solana confirmTransaction() results.
 *
 * This script performs read-only source verification and an in-memory mock. It
 * does not contact Solana, use credentials, submit transactions, or modify the
 * target repository.
 *
 * Usage:
 *   node poc/verify-unchecked-confirmations.mjs CHECKOUT_DIRECTORY
 */

import { readFile } from "node:fs/promises";
import { resolve } from "node:path";

const repositoryRoot = process.argv[2];

if (!repositoryRoot) {
  console.error("Usage: node verify-unchecked-confirmations.mjs CHECKOUT_DIRECTORY");
  process.exit(2);
}

const sourceChecks = [
  {
    file: "app/app/api/devnet-airdrop/route.ts",
    label: "devnet-airdrop mirror creation persists after unchecked confirmation",
    orderedNeedles: [
      "const createSig = await connection.sendRawTransaction",
      'await connection.confirmTransaction(createSig, "confirmed")',
      'supabase.from("devnet_mints").upsert',
    ],
  },
  {
    file: "app/app/api/devnet-airdrop/route.ts",
    label: "devnet-airdrop token delivery marks success after unchecked confirmation",
    orderedNeedles: [
      "const txSig = await connection.sendRawTransaction",
      'await connection.confirmTransaction(txSig, "confirmed")',
      "mintSucceeded = true",
    ],
  },
  {
    file: "app/app/api/devnet-mint-token/route.ts",
    label: "existing-mint airdrop returns success after unchecked confirmation",
    orderedNeedles: [
      "const airdropTxSig = await connection.sendRawTransaction",
      'await connection.confirmTransaction(airdropTxSig, "confirmed")',
      "mintSucceeded = true",
      'status: "already_exists"',
    ],
  },
  {
    file: "app/app/api/devnet-mint-token/route.ts",
    label: "new mint is inserted after unchecked confirmation",
    orderedNeedles: [
      "const sig = await connection.sendRawTransaction",
      'await connection.confirmTransaction(sig, "confirmed")',
      'supabase.from("devnet_mints").insert',
    ],
  },
  {
    file: "app/app/api/devnet-mirror-mint/route.ts",
    label: "mirror mapping is upserted after unchecked confirmation",
    orderedNeedles: [
      "sig = await connection.sendRawTransaction",
      'await connection.confirmTransaction(sig, "confirmed")',
      'supabase.from("devnet_mints").upsert',
    ],
  },
  {
    file: "app/app/api/playground/faucet/route.ts",
    label: "playground USDC faucet records claim after unchecked confirmation",
    orderedNeedles: [
      "usdcSig = await connection.sendRawTransaction",
      "await connection.confirmTransaction(",
      "const nextClaimAt = recordClaim(walletAddress)",
      "funded: true",
    ],
  },
  {
    file: "app/app/api/playground/faucet/route.ts",
    label: "playground SOL faucet marks airdrop success after unchecked confirmation",
    orderedNeedles: [
      "const s = await pubConn.requestAirdrop",
      'await pubConn.confirmTransaction(s, "confirmed")',
      "solAirdropped = true",
    ],
  },
];

function assertOrdered(source, needles, label) {
  let cursor = 0;

  for (const needle of needles) {
    const index = source.indexOf(needle, cursor);
    if (index === -1) {
      throw new Error(`${label}: missing or out-of-order source fragment: ${needle}`);
    }
    cursor = index + needle.length;
  }
}

async function verifySource() {
  for (const check of sourceChecks) {
    const absolutePath = resolve(repositoryRoot, check.file);
    const source = await readFile(absolutePath, "utf8");
    assertOrdered(source, check.orderedNeedles, check.label);
    console.log(`[source confirmed] ${check.label}`);
  }
}

const failedConfirmation = {
  context: { slot: 123 },
  value: { err: { InstructionError: [0, "Custom"] } },
};

async function vulnerableFlow(confirmTransaction) {
  const state = { successCommitted: false };
  await confirmTransaction("mock-signature", "confirmed");
  state.successCommitted = true;
  return state;
}

function assertSuccessfulConfirmation(confirmation) {
  if (confirmation?.value?.err === null) return;
  throw new Error(`confirmed but failed on-chain: ${JSON.stringify(confirmation?.value?.err)}`);
}

async function fixedFlow(confirmTransaction) {
  const state = { successCommitted: false };
  const confirmation = await confirmTransaction("mock-signature", "confirmed");
  assertSuccessfulConfirmation(confirmation);
  state.successCommitted = true;
  return state;
}

async function demonstrateResolvedErrorSemantics() {
  const mockedConfirm = async () => failedConfirmation;
  const vulnerableState = await vulnerableFlow(mockedConfirm);

  if (!vulnerableState.successCommitted) {
    throw new Error("Vulnerable mock unexpectedly stopped before the success transition");
  }

  let fixedThrew = false;
  try {
    await fixedFlow(mockedConfirm);
  } catch (error) {
    fixedThrew = true;
    console.log(`[safe control rejected] ${error.message}`);
  }

  if (!fixedThrew) {
    throw new Error("Safe mock accepted a confirmation with non-null value.err");
  }

  console.log(
    `[mock confirmed] vulnerable successCommitted=${vulnerableState.successCommitted}; safe flow rejected before commit`,
  );
}

await verifySource();
await demonstrateResolvedErrorSemantics();
console.log("PoC completed: all unchecked confirmation paths and mock semantics were confirmed.");

Remediation

Use the repository's existing helper immediately after every remaining confirmation and before any success-only mutation:

const confirmation = await connection.confirmTransaction(
  { signature, blockhash, lastValidBlockHeight },
  "confirmed",
);

assertSuccessfulConfirmation(confirmation, "Devnet mirror mint creation");

For the six signature-only sites, retain both blockhash and lastValidBlockHeight from getLatestBlockhash() and migrate to the blockheight-aware strategy. This removes the deprecated timeout-based overload and gives expiry handling the transaction's actual lifetime. That migration complements, but does not replace, the required value.err check.

Apply the check at these locations:

  • app/app/api/devnet-airdrop/route.ts:371 and :760;
  • app/app/api/devnet-mint-token/route.ts:313 and :402;
  • app/app/api/devnet-mirror-mint/route.ts:362;
  • app/app/api/playground/faucet/route.ts:235-238 and :271.

Keep each side effect after the assertion. Specifically:

  • Never write devnet_mints for a failed create/initialize transaction.
  • Do not set mintSucceeded, funded, or solAirdropped from a returned signature alone.
  • Release a reserved claim when the product's policy is "successful delivery per window." In devnet-mint-token's existing-mint branch, decide explicitly whether a failed optional airdrop should consume the grant; the current catch treats an airdrop exception as non-fatal and retains the claim, so merely adding a throwing helper will preserve that behavior unless the catch is also changed.
  • Continue the playground SOL RPC loop after a confirmed execution failure when the failure is classified as retryable.
  • Record the signature, operation, endpoint, and serialized non-sensitive execution error in Sentry before returning a failure response.

Add route-level regression tests for all three side-effect classes: persistence, claim lifecycle, and response flags. Each test should cover explicit success (err === null), resolved execution failure (err !== null), rejected confirmation, and—in code using the shared structural helper—an incomplete result that must fail closed. A static check that flags an awaited confirmTransaction() whose return value is unused would prevent another partial remediation.

No fixed release for these seven operations was identified during this review. PR #2436 is an implementation reference for the helper and testing style, not evidence that these residual sites are fixed.

Summary

The assessed source proves that seven operations across devnet-airdrop, devnet-mint-token, devnet-mirror-mint, and playground/faucet advance success-only state without validating the SignatureResult returned by confirmTransaction(). The locked @solana/web3.js 1.98.4 implementation confirms that an error-bearing signature notification can resolve the confirmation promise, and the executed offline control demonstrates that the affected continuation commits success for that resolved-error shape while the repository's helper rejects it. Existing catches, preflight, and the status-poll rejection path remain meaningful controls, but none establishes the missing postcondition at these seven sites.

What remains conditional is adversarial triggering and live deployment impact. No reliable caller-controlled method was shown to force the required post-submission execution error across these routes, no shared devnet transaction was submitted, and no mainnet or production-value effect was demonstrated. The supported outcomes remain false-success responses, consumed retry claims, suppressed fallback, and potentially invalid devnet mint mappings. Low severity and classification as residual coverage in the #2435/#2436 family therefore remain appropriate.

The requested maintainer action is to apply the existing fail-closed confirmation helper at all seven locations before any response, flag, claim, fallback decision, or database write advances; migrate the six signature-only confirmations without treating that migration as a substitute for result validation; and add route-level success, resolved-error, rejected-confirmation, and incomplete-result regression tests for every affected side-effect class.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions