| 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:
devnet-airdrop mirror creation: raw transaction submission, discarded confirmation, then devnet_mints upsert.
devnet-airdrop token delivery: raw transaction submission, discarded confirmation, then mintSucceeded = true.
devnet-mint-token existing-mint delivery: raw transaction submission, discarded confirmation, mintSucceeded = true, then the already_exists response.
devnet-mint-token new-mint creation: raw transaction submission, discarded confirmation, then devnet_mints insert.
devnet-mirror-mint creation: raw transaction submission, discarded confirmation, then devnet_mints upsert.
- Playground USDC funding: raw transaction submission, discarded blockheight-aware confirmation, claim recording, then
funded: true.
- 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:
devnet-airdrop mirror creation: raw transaction submission, discarded confirmation, then database upsert.
devnet-airdrop token delivery: raw transaction submission, discarded confirmation, then mintSucceeded = true.
devnet-mint-token existing-mint delivery: raw transaction submission, discarded confirmation, mintSucceeded = true, then the already_exists response.
devnet-mint-token new-mint creation: raw transaction submission, discarded confirmation, then database insert.
devnet-mirror-mint creation: raw transaction submission, discarded confirmation, then database upsert.
- Playground USDC funding: raw transaction submission, discarded confirmation, claim recording, then
funded: true.
- 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.
playgroundcommit8d3e280ab6276679f5f9e0c453734f07845ac53a@solana/web3.js1.98.4Executive 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 returnedSignatureResult; it then advances success-only API or database state without requiringconfirmation.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.jsdependency. In version 1.98.4,confirmTransaction()returnsPromise<RpcResponseAndContext<SignatureResult>>, andSignatureResult.errcarries the transaction-processing result. Crucially, the subscription callback can resolve that promise with the returnedSignatureResultwithout converting a non-nullerrinto an exception. An initial status-poll path does reject onvalue.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_mintscan 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.js1.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 inpoc/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_KEYPAIRto 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:
The application declares
@solana/web3.jsas^1.98.4inapp/package.json:26, andpnpm-lock.yaml:52-54resolves 1.98.4. In that release,Connection.confirmTransaction()returns a response whosevalueis aSignatureResult, defined witherr: TransactionError | null. The implementation's signature-subscription callback resolves with{ context, value: result }regardless of whetherresult.erris null. By contrast, the initialgetSignatureStatus()check rejects when it observes an error. This split behavior is why surroundingtry/catchblocks 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,SignatureResultdefineserr: TransactionError | null. The implementation ofconfirmTransaction()and its shared confirmation promise then shows the unchecked subscription resolution and the contrasting status-poll rejection. The application independently encodes the same rule inapp/lib/transaction-confirmation.ts:17-44:assertSuccessfulConfirmation()accepts only an explicitvalue.err === nulland throws for an execution error or an incomplete response.Two corrected routes provide strong local controls:
app/app/api/faucet/route.ts:193-199validates the primary faucet's SOL confirmation before leaving its fallback loop.app/app/api/auto-fund/route.ts:212-221validates the auto-fund USDC mint before settingresults.usdc_minted.The assessed source is an unversioned
playgroundcommit. 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 commit8d3e280ab6276679f5f9e0c453734f07845ac53a. 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 aSignatureResultwhoseerrfield records an on-chain execution failure. Because the handlers discard that result, their surroundingtry/catchblocks 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 explicitvalue.err === nullpostcondition. 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:
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 resolvedSignatureResultcontainingerr.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
app/app/api/devnet-airdrop/route.ts,resolveServerOwnedDevnetMint(), lines 369-397devnet_mintsupsertapp/app/api/devnet-airdrop/route.ts,POST(), lines 755-817mintSucceeded = trueat line 791 and a success body at lines 810-817app/app/api/devnet-mint-token/route.ts, existing-mint branch, lines 310-325mintSucceeded = trueand anairdropTokensresponseapp/app/api/devnet-mint-token/route.ts, new-mint branch, lines 395-418 and 467-478devnet_mintsinsert and astatus: "created"responseapp/app/api/devnet-mirror-mint/route.ts,POST(), lines 359-413app/app/api/playground/faucet/route.ts, USDC branch, lines 230-256 and 286-294recordClaim()andfunded: trueapp/app/api/playground/faucet/route.ts, SOL best-effort branch, lines 263-291solAirdropped = trueand termination of the RPC fallback loopThe first database-integrity path is particularly direct:
The generated public key exists locally regardless of transaction execution. It becomes a valid on-chain mint only if the
createAccountandinitializeMintinstructions 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-airdropis similarly explicit:The
finallyblock releases the reserved claim only whenmintSucceededremains 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 addedvalue.errchecks there and inuseCreateMarket.tsand 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:
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:
devnet-airdropand playground USDC claims can remain reserved after nondelivery. The applicable window is 24 hours fordevnet-airdropand one hour for the playground faucet.funded: true,sol_airdropped: true,airdropTokens, orstatus: "created"even though the corresponding execution failed.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-tokensource 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
value.err === nullis the positive control: the success transition is allowed.assertSuccessfulConfirmation()rejects the same non-nullvalue.erraccepted by the vulnerable continuation. This isolates the missing check rather than an RPC transport issue.confirmTransaction()rejects, the existing catches run; that negative control confirms the problem is specifically the resolved-error form.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.mjsis a dependency-free, read-only source verifier plus a small in-memory control-flow demonstration.README.mdgives 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 !== nulldiffers from a fail-closed result check.Prerequisites and report-relative execution
Use Node.js 18 or newer and a checkout of
dcccrypto/percolator-launchat the assessed commit. From the directory containing this report, the following commands create a siblingtarget/checkout at the exact revision and run the verifier: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 oftarget; the supplied argument must contain the repository'sapp/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:
devnet-airdropmirror creation: raw transaction submission, discarded confirmation, thendevnet_mintsupsert.devnet-airdroptoken delivery: raw transaction submission, discarded confirmation, thenmintSucceeded = true.devnet-mint-tokenexisting-mint delivery: raw transaction submission, discarded confirmation,mintSucceeded = true, then thealready_existsresponse.devnet-mint-tokennew-mint creation: raw transaction submission, discarded confirmation, thendevnet_mintsinsert.devnet-mirror-mintcreation: raw transaction submission, discarded confirmation, thendevnet_mintsupsert.funded: true.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:
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 entercatchmerely because the returned data contains a non-nullerrfield.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: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.jsnor executes itsConnection.confirmTransaction()implementation. The proposition that version 1.98.4 can fulfill the confirmation promise from a signature-subscription callback with a non-nullSignatureResult.erris 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.mdOffline 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 whosevalue.erris non-null. It is not a live Solana or route-level exploit.Prerequisites
dcccrypto/percolator-launchat commit8d3e280ab6276679f5f9e0c453734f07845ac53aReport-relative execution
From the directory containing the parent report, create a sibling
target/checkout at the assessed revision and run:If the pinned source is already available, pass its checkout-directory name in place of
target. The supplied directory must contain the repository'sapp/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:
devnet-airdropmirror creation: raw transaction submission, discarded confirmation, then database upsert.devnet-airdroptoken delivery: raw transaction submission, discarded confirmation, thenmintSucceeded = true.devnet-mint-tokenexisting-mint delivery: raw transaction submission, discarded confirmation,mintSucceeded = true, then thealready_existsresponse.devnet-mint-tokennew-mint creation: raw transaction submission, discarded confirmation, then database insert.devnet-mirror-mintcreation: raw transaction submission, discarded confirmation, then database upsert.funded: true.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:
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 === nulland 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:
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
catchmerely because the returned object containsvalue.err.The verifier does not import
@solana/web3.jsor executeConnection.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-nullSignatureResult.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.mjsThe complete read-only verifier source used for the offline validation is embedded below.
Remediation
Use the repository's existing helper immediately after every remaining confirmation and before any success-only mutation:
For the six signature-only sites, retain both
blockhashandlastValidBlockHeightfromgetLatestBlockhash()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 requiredvalue.errcheck.Apply the check at these locations:
app/app/api/devnet-airdrop/route.ts:371and:760;app/app/api/devnet-mint-token/route.ts:313and:402;app/app/api/devnet-mirror-mint/route.ts:362;app/app/api/playground/faucet/route.ts:235-238and:271.Keep each side effect after the assertion. Specifically:
devnet_mintsfor a failed create/initialize transaction.mintSucceeded,funded, orsolAirdroppedfrom a returned signature alone.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.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 awaitedconfirmTransaction()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, andplayground/faucetadvance success-only state without validating theSignatureResultreturned byconfirmTransaction(). The locked@solana/web3.js1.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.