Testnet mining rewards claim flow - #656
Conversation
Settings gets its Mining Rewards entry back. Check eligibility picks a software wallet (directly, or through the wallet list when there are several), then shows per testnet how many blocks that wallet mined and what it pays out. Claim lets the user pay the reward to any of their transparent or encrypted accounts or a typed address, proves ownership of each matched miner address with the SDK airdrop API, and posts the claims to the airdrop-claim server. Payouts come from the bundled "Miner Stats" CSV exports, one per chain, loaded on demand. The exports name their columns differently, so the parser finds address, blocks and mainnet reward by keyword. A share that rounds to nothing is floored to 0.1 as a thank-you. The wallet picker and the one-or-many wallet navigation move out of wallet settings into shared helpers so both flows use them.
The raw exports carry testnet payouts, pool shares and cumulative columns the wallet never reads. tool/trim_miner_stats.dart rewrites the files in assets/testnet_data down to `address,blocks,reward`; the CSV reader moves into shared/utils so the tool and the service parse both the raw exports and the trimmed form with one implementation.
Address formats changed several times across the testnets, so one wallet often owns more than one row of a chain's table. Each chain now keeps its owned rows and lists them, shortened, with their payout under the chain line. A row whose key era the claim server does not accept yet is labelled instead of priced, and stays out of the chain total and the claim gate so the flow never offers a reward it cannot collect.
…p-claim
AirdropClaimService owns the POST /claim contract: one request per
claimable matched address, Dilithium claims signed from the mnemonic
right before they are sent so the expiry stays inside the server's
window, wormhole ownership proofs built in-process, and the server's
{"error"} body surfaced on rejection. The wallet's mining rewards
service now only resolves the recovery phrase and delegates.
A server test spawns a locally built airdrop-check with a snapshot
holding one Dilithium and one wormhole address of a test mnemonic, runs
the real matcher and provers, and checks both claims are recorded for
the payout address and refused a second time. It is skipped unless
AIRDROP_CHECK_BIN points at the binary.
The server keeps one row per address and answers a repeat with 409. A submission that failed halfway used to hit that on the addresses it had already recorded and stop before reaching the missing ones. A 409 now counts as done and the loop carries on; the server test retries the full set and checks nothing changes.
Settings gets the Airdrop row with its icon, right after Wallet. The entry page carries the hero copy; the wallet list shows initials and account counts. One wallet screen now covers the check (chain counter, rows filling in), the results (total, per-chain payout, blocks), the per-chain bottom sheet listing every owned address with its payout, the no-mining page, and the already-claimed page fed by a claim record stored per wallet. Claiming goes pay-to (own accounts, active one preselected, or another typed address with inline validation), then a confirm page that owns submission and shows the failed page with Try Again when the server cannot be reached, then the submitted page. Groundwork: AirdropClaimRecord stored through SettingsService and cleared with the wallet, a trailing slot on BottomSheetContainer for the sheet's close button, integer and hundredths formatters, a day and month date helper, and a debug outcome picker on the entry page so every state can be walked through without a mining wallet.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
Verdict (advisory): Request changes
Blocking findings:
-
[P1] Gate the flow until the configured claim service is actually reachable.
quantus_sdk/lib/src/constants/app_constants.dart:22points production submissions athttps://airdrop.quantus.com, whilemobile-app/lib/v2/screens/settings/settings_screen.dart:79-95exposes the entry unconditionally. At review time both authoritativequantus.comnameservers return no A/AAAA record for that host, and an HTTPS/snapshotprobe fails at DNS resolution. As written, every real Submit Claim attempt fails. Deploy and smoke-test the endpoint before exposing the row, or protect the feature with a remotely controlled availability gate. -
[P1] Make the bundled entitlement amounts match the server's authoritative snapshot.
mobile-app/lib/shared/utils/miner_stats_csv.dart:42floors every zero/missing per-testnet reward to 0.10, and the committed CSVs already contain that transformed output. Comparing this head's four assets withQuantus-Network/airdrop-claimmainat0f54db79df2e8645c4a8ed175fa969574f49de27gives 10,039.20 QTC in the app versus 10,000.09 QTC on the server. In particular, 385 addresses receive 38.60 QTC in the app but zero on the server, and six funded addresses are overstated by another 0.51 QTC. The current server rejects zero-reward rows, andAirdropClaimServicestops the batch on that response, so affected wallets can be shown a nonexistent entitlement and may not finish otherwise-valid claims. Coordinate the floor/aggregation change with the server before launch (or consume its/snapshotas the source of truth), and add an address-to-amount/hash consistency check against the deployed snapshot. -
[P1] Do not accept an unqualified 409 as successful idempotency.
quantus_sdk/lib/src/services/airdrop_claim_service.dart:56treats every Conflict as done, but the current server's 409 only saysaddress already claimed; it neither returns nor checks the originalclaim_account. After a partial batch failure, the UI lets the user back out and choose another destination. A retry then leaves earlier rows bound to the old destination, records later rows for the new one, andmobile-app/lib/services/mining_rewards_service.dart:57-64stores and displays the entire total as if it were going to the new destination. Lost local preferences/reinstallation creates the same false-success path. Make retries idempotent only when the recorded destination matches (for example, have the server return/validate the existing record), or persist and enforce one destination for the incomplete batch.
Validation on base 180d772a5c45d027462251bfa97076b4e8916e4c, head b9be6f589bc5b15f46fa4b577fa4860721a60d35:
git diff --checkpassed.- Workspace formatting passed (749 files, 0 changes).
- 49 focused mobile mining-rewards tests passed.
- 8 focused SDK claim/record/export tests passed.
- Local analysis was capped at 10 seconds as required: cold-wallet, miner, and SDK completed cleanly before the mobile package started; GitHub's full Analyze check passed on this exact head.
- GitHub dependency cooldown passed.
…s done The server keeps one row per address and answers a repeat with a bare 409, so a retry after a partial failure could bind later addresses to a new payout address while earlier ones stayed with the old one. The client now reads GET /unpaid on a 409 and treats the address as done only when the recorded payout address is the one being submitted; otherwise the batch stops with AirdropClaimTaken naming the recorded address. Failures carry how far the batch got, so the confirm page locks the payout address once anything is recorded and, for a taken address, offers to finish the claim to the recorded one. The claim endpoint is the deployed airdrop-claim.quantus.com.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
Verdict (advisory): Request changes
The previously reported endpoint-availability blocker is resolved: airdrop-claim.quantus.com resolves and both /snapshot and /unpaid returned HTTP 200 during this review. Three blocking correctness issues remain.
-
[P1] Make the bundled entitlements match the authoritative server snapshot.
mobile-app/lib/shared/utils/miner_stats_csv.dart:42floors every zero or missing per-testnet reward to 0.10, and the committed CSVs already contain that transformed output. This head's four assets aggregate to 10,039.20 QTC across 775 addresses, while the live/snapshot(SHA-25612190d5f...e7672, matchingQuantus-Network/airdrop-claimmain at0f54db79) contains 10,000.09 QTC across 390 addresses. The app gives 385 server-absent addresses 38.60 QTC in total (283 of them are non-Planck/Dilithium rows that become claimable when matched), and overstates six server-funded Dilithium addresses by another 0.51 QTC. Affected wallets are shown rewards the server rejects with 404, so a batch can never complete; the six shared addresses are also displayed and persisted at the wrong total. Coordinate the floor/aggregation change with the server before launch, or derive eligibility and amounts from/snapshot, and add an exact address-to-amount/hash consistency check. -
[P1] Do not treat a paid claim whose destination cannot be recovered as a successful retry. On a 409,
quantus_sdk/lib/src/services/airdrop_claim_service.dart:87-92accepts the conflict wheneverrecordedTo[match.address]is null. But_recordedDestinationsexplicitly reads/unpaid, whose contract omits claims after they are paid (:103-111); the new unit test atquantus_sdk/test/services/airdrop_claim_service_test.dart:108-112codifies that missing row as success. After preferences are lost or the app is reinstalled, a user can pick any new destination, receive 409 for an already-paid address, and the app then stores and displays that new destination and the full amount as if it had just submitted there (mobile-app/lib/services/mining_rewards_service.dart:57-64). A 409 must count as done only when the server returns the original destination and it exactly matches, or the server needs a status/409 response that includes paid records so the UI can report the actual completed claim. -
[P1] Check the whole batch's existing destination before writing any new address. The loop posts addresses before it learns about a later conflict (
quantus_sdk/lib/src/services/airdrop_claim_service.dart:79-99). Withqzaunclaimed andqzbalready recorded to B, submitting the batch to A first recordsqza -> A, then discoversqzb -> B—the exact ordering exercised byairdrop_claim_service_test.dart:114-133. The UI's “Continue with that address” retry (mobile-app/lib/v2/screens/mining_rewards/mining_rewards_confirm_screen.dart:75-79) then immediately conflicts onqza -> A, so the batch is irreversibly split and cannot converge on either destination. Preflight all matched addresses and require zero or one existing payout destination before the first POST, or make the server accept the batch atomically.
Validation on base 180d772a5c45d027462251bfa97076b4e8916e4c, head 2de9de1eb6ff007f33852f3a01bd32c836f0ba4c:
git diff --checkpassed; dry-run formatting passed for all 42 changed Dart files.- 51 focused mobile mining-rewards/formatting tests passed.
- 10 focused SDK claim/record/export tests passed; the optional local
airdrop-checkintegration test was skipped becauseAIRDROP_CHECK_BINwas not configured. - Local analysis was capped at 10 seconds as required: cold-wallet, miner, and SDK completed cleanly before mobile analysis began; GitHub's full Analyze check passed on this exact head.
- The live
/snapshotand/unpaidprobes passed, and the live snapshot source hash matches currentairdrop-claimmain. GitHub dependency cooldown checks passed.
The claim server keeps one row per address and answers a repeat with 409, and nothing else produces one, so a 409 simply means that address is submitted. The client no longer consults /unpaid or stops at the first rejection: every address is tried on every attempt, 200 and 409 both count, and a failure carries how many got through. The confirm page shows "Some claims have been submitted, please try again" when it was partial, the submitted page opens with "All claims have been submitted.", and Change Details stays available throughout.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT 5.6 Sol
Verdict (advisory): Request changes
The entitlement source has been aligned on airdrop-claim/main, and the endpoint/CI issues from the earlier heads are resolved. Two blocking correctness issues remain.
-
[P1] Do not report an unqualified 409 as a claim to the currently selected destination.
quantus_sdk/lib/src/services/airdrop_claim_service.dart:64-80treats every Conflict as submitted, whilemobile-app/lib/v2/screens/mining_rewards/mining_rewards_confirm_screen.dart:173-180deliberately leaves Change Details available after a partial submission andmobile-app/lib/services/mining_rewards_service.dart:57-64persists the newly selected destination for the full total. The current server contract returns only{"error":"address already claimed"}on 409; it does not return or validate the recorded payout address. Therefore, if address A was recorded to X before address B failed, changing details to Y makes A's retry return 409, records B to Y, and then shows/stores the entire batch as “Paying to Y” even though it is split between X and Y. Reinstalling or losing preferences produces the same false-success result for a completed batch. The integration test atquantus_sdk/test/services/airdrop_claim_server_test.dart:77-82explicitly demonstrates that submitting to another address returns success to the client while the server rows remain unchanged. Make the server's idempotent response expose/validate the original destination (including paid rows) and require an exact match before success, or provide an atomic batch/status contract; until then the client must not turn an unqualified 409 into a claim record for the chosen address. -
[P1] Keep the flow gated until the matching reward snapshot is live. The four bundled assets aggregate to 775 addresses and 10,039.20 QTC and now match
Quantus-Network/airdrop-claimmain at01a7630eexactly. However,https://airdrop-claim.quantus.com/snapshotstill serves source hash12190d5f...e7672: 390 addresses and 10,000.09 QTC. There are 385 app-only addresses worth 38.60 QTC, plus six shared addresses overstated by another 0.51 QTC. Becausequantus_sdk/lib/src/constants/app_constants.dart:21-22points submissions at that live service and Settings exposes the flow unconditionally, affected users see entitlements that the server rejects and cannot complete the batch. Deploy and smoke-test the 775-address snapshot before this client can ship, or gate the entry on an exact snapshot version/hash.
Validation on base 180d772a5c45d027462251bfa97076b4e8916e4c, head 2b1cd8c53216430254318677af187e7b877510fc:
git diff --checkpassed.- Workspace dry-run formatting passed (749 Dart files, 0 changes).
- 50 focused mobile mining-rewards/formatting tests passed.
- 8 focused SDK claim/record/export tests passed. The optional local
airdrop-checkintegration test was not run becauseAIRDROP_CHECK_BINwas not configured; its current server-contract assertions were inspected directly. - Local analysis was capped at 10 seconds as required: cold-wallet, miner, and SDK completed cleanly before mobile analysis began; GitHub's full Analyze check passed on this exact head.
- Live snapshot and current
airdrop-claim/mainwere rechecked immediately before posting; GitHub Analyze and dependency-cooldown checks passed.
|
Re the last review round, checked against the live server at
The one scenario left intentionally unresolved is a user who submits part of a batch, changes the payout address, and resubmits: the addresses recorded first stay bound to the first address, and the app cannot tell, since the server does not return the recorded payout address. We accept that. Verified end to end against a local |
What
The testnet mining rewards claim flow, built to the Figma "Airdrop Flow" designs (section
546:5277).Every state was walked on the iOS simulator against the Figma frames using the debug outcome picker on the Mining Rewards entry page (
AppConstants.debugMiningRewards, debug builds only): mixed / some / below / none / claimed / fail.Server client (SDK)
AirdropClaimServiceinquantus_sdkowns the wire contract with airdrop-claim: one request per claimable matched address, duplicates collapsed, Dilithium claims signed from the mnemonic right before each request (10-minute expiry inside the server's 15-minute window), wormhole claims carrying the in-process ownership proof, and the server's{"error"}text surfaced on rejection. The server keeps one row per address and answers a repeat with 409, so 200 and 409 both count as submitted; every address is tried on every attempt, and a failure reports how many got through. The confirm page then says "Some claims have been submitted, please try again" (or "Couldn't submit right now" when none did), and the submitted page opens with "All claims have been submitted." Verified end to end bytest/services/airdrop_claim_server_test.dartagainst a locally builtairdrop-check(skipped unlessAIRDROP_CHECK_BINis set).Data
mobile-app/assets/testnet_data/, loaded on demand. Onlyaddress,blocks,rewardships:tool/trim_miner_stats.dartrewrites the raw "Miner Stats" exports down to those three columns. Drop in fresh exports, rundart run tool/trim_miner_stats.dartfrommobile-app, commit.shared/utils/miner_stats_csv.dart) accepts raw and trimmed forms, finding the address, blocks and mainnet-reward columns by keyword.Groundwork
AirdropClaimRecordstored per wallet throughSettingsService(cleared with the wallet, invalidated on logout).BottomSheetContainergained atrailingslot for the sheet's close button.NumberFormattingService.formatInteger/formatHundredths,DatetimeFormattingService.formatDayMonth.SelectWalletScreentakes a destination and the one-or-many wallet navigation lives inpushForSoftwareWallet; wallet settings uses the same helper.https://airdrop-claim.quantus.com.Tests