Update the code to use fuel-core 0.48.3 - #84
Merged
Conversation
PR SummaryLow Risk Overview Reviewed by Cursor Bugbot for commit d9a2467. Bugbot is set up for automated code reviews on this repo. Configure here. |
xgreenx
enabled auto-merge (squash)
April 18, 2026 18:20
xgreenx
disabled auto-merge
April 18, 2026 18:21
xgreenx
enabled auto-merge (squash)
April 18, 2026 23:45
Testnet reports `nodeVersion 0.48.3`; the faucet computes `max_fee` itself in order to size the chained change output it hands back to itself, so its pins have to match the node it talks to rather than merely be close to it. 0.48.1-0.48.3 carry no fee-model or transaction-API change. The one that matters to this service is fuel-core#3264, which rolls back stale preconfirmations in the mempool and removes the dependent transactions that hang off them - exactly the shape of the faucet's transaction chain. fuel-vm 0.66.4 is contract-storage work only. `ethnum` moves 1.5.2 -> 1.5.3 in the lockfile as well. 1.5.2 transmutes `()` into `TryFromIntError`, which stopped being zero-sized after the CI toolchain pin, so the repository cannot be built locally on a current stable without it. It compiles either way on the pinned 1.93.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SDK's coin selection and fee arithmetic moved to `u128`, so `Config::dispense_amount` followed, but a coin output amount is still `u64`. Two `as u64` casts sat on that boundary. `DISPENSE_AMOUNT` used to be rejected at startup by `parse::<u64>()`; parsed as `u128` it would now truncate silently and send the wrong amount to the user. The stable fee change is worse: the faucet writes it into the coin output it pays itself and the next dispense in the chain spends that UtxoId claiming exactly that amount, so a truncated value produces an input the chain does not have and the node rejects every descendant. Neither is reachable with any sane configuration. Both are one `try_from` away from being unreachable at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--platform=$BUILDPLATFORM` was a no-op under the single-arch buildx job this branch was written against, and it is a no-op under the native per-arch runners #87 introduced on master. It stops being a no-op the moment the image job falls back to emulation, at which point the builder stage would produce a build-arch binary and copy it into a target-arch runtime image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's `RUST_VERSION: 1.93.0` does not take effect for the Rust jobs: `actions-rs/toolchain@v1` installs the pinned toolchain but never activates it, so `rustc -V` in the clippy job reports 1.98.0 - whatever stable the runner image ships. The pin only really binds the Dockerfile base image. The consequence is that this repository's lint surface drifts with the GitHub runner rather than with anything in the repo, which is why `useless_format` in `recaptcha::verify` passed in April and fails today. None of these are new code; they are the three findings from running clippy on 1.97/1.98/nightly instead of the nominal pin. Fixing the workflow needs a token with `workflow` scope, so it is left alone here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voxelot
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Brings the faucet from
fuel-core 0.39to the version testnet actually runs, and finishesthe branch that production has been deployed from since April.
Why this matters more than a normal dependency bump
Three different things are called "the faucet" right now:
master/ latest releasev0.7.6(Oct 2024)cfee2e4nodeInfo.nodeVersion)cfee2e4is the CI merge build of this pull request ate975ea5. It was pinned byfuel-deployment-v2#6733 on 19 April 2026, when testnet moved to fuel-core 0.48 and thereleased faucet stopped being able to transact against it. So production has been running an
unmerged branch for four months:
master, the latest release and the deployed artefact arethree different code bases, nothing rebuilds
cfee2e4, and anyone debugging production frommasteris reading different code.The faucet computes
max_feeitself in order to size the chained change output it handsback to itself, so it depends on the node's fee arithmetic, not just its API. That is why
the version has to match the deployed network rather than merely be "recent" — a mismatch
starts cleanly, passes its health check and fails every dispense (cf. #77:
InsufficientMaxFee { max_fee_from_policies: 314900, max_fee_from_gas_price: 314931 }).What is in this PR
The four original commits (fuel-core 0.48.0, Rust 1.93, edition 2024), plus:
0.48.0→0.48.3, fuel-vm crates0.66.3→0.66.4, so thepins equal the version testnet reports. 0.48.1–0.48.3 are node-side fixes with no fee-model
or transaction-API change; #3264 ("rollback stale preconfirmations in the mempool …
restoring spent inputs and removing dependent transactions") is the one that touches this
service's world, and it makes a broken chain clean up rather than linger.
0.48.3and not=0.48.2. The 0.48.3 tag was released as a node butfuel-core-bin0.48.3 was never published to crates.io, so a consumer that pullsfuel-core-binhas to stay at=0.48.2. The faucet does not:fuel-core-binis not inthis graph at all (
cargo tree -i fuel-core-binfinds nothing, and it is absent fromCargo.lock), because the dev-dependency isfuel-corewithdefault-features = false.Every crate this repository names —
fuel-core,fuel-core-client,fuel-core-txpool,fuel-core-chain-config— is published at 0.48.3, so the faucet can pin the versiontestnet actually runs, which for this service is the whole point.
c-kzg. fuel-core 0.48 migrated the relayer to alloy 1.x, andc-kzg2.x setslinks = "ckzg", which turns a mixed-alloy graph into a hard cargoresolution failure rather than a compile error. That does not apply here:
default-features = falsekeeps the relayer out, and neitheralloynorc-kzgappearsin
Cargo.lock.ethnum1.5.2→1.5.3(lockfile only). 1.5.2 does not compile on any toolchain newerthan the CI pin —
mem::transmute(())intoTryFromIntError, which stopped beingzero-sized. It builds on 1.93.0 either way; without the bump nobody can build this
repository locally on a current stable.
u128→u64conversions (below).--platform=$BUILDPLATFORMfrom the builder stage. It was a no-op under the oldsingle-arch buildx job, and it is a no-op under chore(docker): native multi-arch via shared workflow #87's native per-arch runners, but it would
silently produce a build-arch binary inside a target-arch image if the image job ever went
back to emulation.
The branch is still behind
master(#87 landed on 26 August). Everything here was built andtested with
mastermerged in locally: it merges cleanly, and the only file that moves is.github/workflows/ci.yml, where #87's shared-workflow docker job and this branch'sRUST_VERSION: 1.93.0are separate hunks. The merge could not be pushed because the tokenavailable here has no
workflowscope, so please let GitHub do that merge — nothing else inthe tree is affected by it.
What nine minor versions actually broke
WalletUnlockedis gone:Wallet::new(PrivateKeySigner::new(key), provider).The provider is no longer an
Option, sowallet.provider()returns&Providerand thethree
.expect("client provider")calls disappear.Provider::consensus_parameters()is async and fallible. The base asset id is readonce per dispense; the SDK caches it client-side, so this is not a per-request round trip.
The two request paths now return a 500 naming the node URL instead of panicking.
u128.get_spendable_resourcesandadjust_for_feetakeu128;Config::dispense_amountand theamount/tokensJSON fields follow. Coin outputs arestill
u64, so the two boundaries are now checked conversions rather thanas u64:a mis-set
DISPENSE_AMOUNTused to fail at startup underparse::<u64>()and wouldotherwise have started truncating silently, and a truncated stable-fee-change would write
an amount into the faucet's own output that the next transaction in the chain then claims
and the node rejects. Neither is reachable with any sane configuration; both are one
try_fromaway from being unreachable at all.Coinlostblock_createdandstatus.get_coinsreturns unspent coins only, sothe
CoinStatus::Unspentfilter in the startup balance is gone rather than silentlymatching nothing.
Bech32Addresswas removed from the SDK.POST /dispensetherefore no longer acceptsfuel1…addresses, only hex/checksum. This is a user-visible behaviour change, itarrived with the original commits on this branch, and it has been live in production since
April; it is called out here because nothing else records it. Re-adding it would mean
hand-rolling bech32 decoding, which is a separate decision.
CoinConfigGeneratormoved tofuel_core_chain_config::coin_config_helpers(new dev-dependency),
CoinConfig::owneris nowOwner::SecretKey(..),starting_gas_pricebecameGasPriceConfig { starting_exec_gas_price, .. },historical_executionhas to be set, the GraphQL query-complexity limit has to be raisedfor the 128-request test, and
rng.gen()isrng.r#gen()under edition 2024.The chained-transaction path — checked deliberately, not just compiled
The dispense path chains transactions: output 0 pays the user, output 1 is a change output
that also pays the user (so the fee-estimate dust is not the faucet's problem), and output 2
is a plain coin back to the faucet whose amount is computed in-process, so the next dispense
can spend
UtxoId::new(tx_id, 2)without waiting for a block. Tips descend frommax_depth * 10_000so the pool never orders a child ahead of its parent. Four things inthis upgrade could have broken that silently:
coin-cacheis still opt-in and still off.cargo tree -e features -i fuels-accountsshows only
default,std,fuel-core-client. This matters: with the feature on,Provider::submitrejects a transaction that spends a coin it saw recently andget_spendable_resourcesexcludes those coins for 30 s — which is exactly the caching Fixes for the testnet faucet #72removed because "it breaks the faucet if transaction is lost in the network". The faucet's
recovery from a lost chain is to drop
last_outputand ask the node what it really owns;that has to keep working.
adjust_for_feestill cannot re-select the coin the chain is spending. When it needsto top up, it passes the transaction's existing base-asset inputs as the exclusion list.
add_base_change_if_neededstill matches any base-assetOutput::Changeregardless ofrecipient. The faucet's change output pays the user, and it still satisfies that check,
so the SDK does not append a fourth output — which means
outputs.last_mut()is stilloutput index 2 and
UtxoId::new(id, 2)still names the coin the faucet just paid itself.Had this become an owner-aware check, every chained input would have pointed at a
zero-amount output and the chain would have broken on the second dispense.
available_balancestill counts message inputs, so a wallet funded by a bridge messagerather than a coin still works.
Verified
cargo build --lockedandcargo test --locked --all --all-features— 5/5 green,including
many_concurrent_requests(128 concurrent dispenses,number_of_retries: 1,3-second blocks; it cannot pass unless transactions really are chaining).
An additional throwaway integration test (not committed) asserted the invariants directly
against an in-process fuel-core 0.48.3 rather than inferring them from wall-clock. Over 64
concurrent dispenses it checked that every faucet transaction has exactly three outputs with
a change output at index 1 and a coin to a single faucet address at index 2; that every
chained input spends output index 2 of its parent; that the chained input amount equals
the parent's precomputed output-2 amount exactly; and that every child's tip is strictly
below its parent's. Result: 63 chained links, 1 chain root — one unbroken chain of 64
transactions — landing up to 8 per block on a 3-second block time:
cargo fmt --checkandcargo clippy --all-features --all-targets -- -D warnings, clean onthe nominal CI pin (1.93.0), on 1.97.1 and on a nightly ahead of the 1.98.0 the runner
actually uses — see below, the pin does not bind.
Live round-trip against testnet with the exact versions this PR pins, via a throwaway
example (not committed):
node_info(max_depth 32,max_tx 160000),health,consensus_parametersincluding the fee parameters the faucet's ownmax_feearithmeticdepends on (
gas_per_byte 1,gas_price_factor 1150000),latest_gas_price,estimate_gas_price,latest_block_height. Testnet reportsnodeVersion 0.48.3, which iswhat this PR pins.
The live faucet wallet, through the same call the dispense handler makes.
0xd205d74d…300e134fholds a single coin of646_026_729_198, andcoins_to_spend(dispense_amount * max_depth * 2 = 128_000_000)— literally whatget_coins()asks for when it seeds a fresh chain — returns one resource. The "one hugeinput" assumption issue Support multiple inputs #54 complains about still holds in production, and the
u128coinselection works against the real node.
The chain is observably working in production right now, which is what this has to
preserve. A dispense from earlier today,
0x389c325f…ee0c5dc1, spends UTXO0x036ce91d…0002— output index 2 of its predecessor — carriestip: 319987(thefourteenth step down from
max_depth * 10_000 = 320_000) and lays out[coin 2_000_000 → user, change 8 → user, coin 646_029_049_207 → faucet]. The in-processtest above asserts exactly that shape.
Log lines the deployment depends on are unchanged:
Faucet Account:/Faucet Balance:atstartup and
dispensed {} tokens to {}per dispense. (Thefilename/line_numberfieldsin the JSON logs shift because
src/routes.rsgrew; nothing queries on those.)One thing found on the way that is not about the dependencies
The Rust version pin in CI does not bind.
actions-rs/toolchain@v1installsRUST_VERSIONbut never activates it. The clippy job's own log saysrustup toolchain install 1.93.0…1.93.0-x86_64-unknown-linux-gnu installed, and thenrustc -Vprintsrustc 1.98.0 (88d9e12ae 2026-08-18). Every Rust job in this workflowruns on whatever stable the runner image ships;
RUST_VERSIONonly really binds theDockerfilebase image.Two visible consequences:
useless_formatinrecaptcha::verifyis original code that passed in April and failstoday. It is fixed here, together with two
useless_borrows_in_formattingintests/dispense.rsthat 1.93.0's clippy also does not see. Verified clean on 1.93.0,1.97.1 and a nightly ahead of 1.98.
ethnumbump is load-bearing, not cosmetic. With the runner on 1.98.0 this branchwould not have compiled at all without it, which is also why
cargo-checkandcargo-testare green here.
Fixing the workflow properly (
dtolnay/rust-toolchain, or arust-toolchain.toml) needs atoken with
workflowscope, so it is left for a follow-up.Not verified
SOPS secret. The in-process test node is authoritative for the transaction shape but by
construction runs the same fuel-core version as the pins, so it cannot catch a mismatch with
the deployed network — the mitigation is that the pins are now equal to the deployed node
version rather than close to it.
neither image has been run — only built.
faucetmetricfamily the binary does not export. Out of scope here — flagged, not touched.
Follow-ups (not in this PR)
v0.8.0and repinfuel-deployment-v2→kustomize/environments/fuel-prod-testnet-hybrid/fuel-apps/faucet/release.yamlfrom
image.tag: "cfee2e4"to the semver tag, so production stops running an unmergedbranch build.
ghcr.io/fuellabs/faucet:845e9b2— this head merged with currentmaster, as a genuineamd64 + arm64 manifest through chore(docker): native multi-arch via shared workflow #87's native builders. That is the same mechanism that
produced the pinned
cfee2e4, so "nothing rebuilds the deployed image" is no longer alive risk while this sits open.
kubernetes.io/arch: amd64node affinity exists because the image used tobe amd64-only. The manifest above carries arm64, so it can go.
actions-rs/toolchain@v1is deprecated and does notactivate what it installs, so every Rust job silently follows the runner image.
README.mdstill documentsMIN_GAS_PRICE, removed when ordering moved to tips, and omitsDISPENSE_LIMIT_INTERVAL,NUMBER_OF_RETRIESandTIMEOUT_SECONDS.src/constants.rsisthe authoritative list.
deployment/charts/is a dead second Helm chart that still advertisesMAX_DISPENSES_PER_MINUTE, an environment variable the binary has not read in years.🤖 Generated with Claude Code