Skip to content

Update the code to use fuel-core 0.48.3 - #84

Merged
xgreenx merged 8 commits into
masterfrom
feature/fuel-core-0.48
Aug 28, 2026
Merged

Update the code to use fuel-core 0.48.3#84
xgreenx merged 8 commits into
masterfrom
feature/fuel-core-0.48

Conversation

@xgreenx

@xgreenx xgreenx commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Brings the faucet from fuel-core 0.39 to the version testnet actually runs, and finishes
the 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:

fuel-core fuels-rs
master / latest release v0.7.6 (Oct 2024) 0.39.0 0.66.8
deployed on testnet — image cfee2e4 0.48.0 0.77.0
testnet node today (nodeInfo.nodeVersion) 0.48.3

cfee2e4 is the CI merge build of this pull request at e975ea5. It was pinned by
fuel-deployment-v2 #6733 on 19 April 2026, when testnet moved to fuel-core 0.48 and the
released 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 are
three different code bases, nothing rebuilds cfee2e4, and anyone debugging production from
master is reading different code.

The faucet computes max_fee itself in order to size the chained change output it hands
back 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:

  • fuel-core family 0.48.00.48.3, fuel-vm crates 0.66.30.66.4, so the
    pins 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.
  • Why 0.48.3 and not =0.48.2. The 0.48.3 tag was released as a node but
    fuel-core-bin 0.48.3 was never published to crates.io, so a consumer that pulls
    fuel-core-bin has to stay at =0.48.2. The faucet does not: fuel-core-bin is not in
    this graph at all (cargo tree -i fuel-core-bin finds nothing, and it is absent from
    Cargo.lock), because the dev-dependency is fuel-core with default-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 version
    testnet actually runs, which for this service is the whole point.
  • No alloy, no c-kzg. fuel-core 0.48 migrated the relayer to alloy 1.x, and
    c-kzg 2.x sets links = "ckzg", which turns a mixed-alloy graph into a hard cargo
    resolution failure rather than a compile error. That does not apply here:
    default-features = false keeps the relayer out, and neither alloy nor c-kzg appears
    in Cargo.lock.
  • ethnum 1.5.21.5.3 (lockfile only). 1.5.2 does not compile on any toolchain newer
    than the CI pin — mem::transmute(()) into TryFromIntError, which stopped being
    zero-sized. It builds on 1.93.0 either way; without the bump nobody can build this
    repository locally on a current stable.
  • Two checked u128u64 conversions (below).
  • Dropped --platform=$BUILDPLATFORM from the builder stage. It was a no-op under the old
    single-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 and
tested with master merged 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's
RUST_VERSION: 1.93.0 are separate hunks. The merge could not be pushed because the token
available here has no workflow scope, so please let GitHub do that merge — nothing else in
the tree is affected by it.

What nine minor versions actually broke

  • Wallet API. WalletUnlocked is gone: Wallet::new(PrivateKeySigner::new(key), provider).
    The provider is no longer an Option, so wallet.provider() returns &Provider and the
    three .expect("client provider") calls disappear.
  • Provider::consensus_parameters() is async and fallible. The base asset id is read
    once 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.
  • Balances widen to u128. get_spendable_resources and adjust_for_fee take u128;
    Config::dispense_amount and the amount/tokens JSON fields follow. Coin outputs are
    still u64, so the two boundaries are now checked conversions rather than as u64:
    a mis-set DISPENSE_AMOUNT used to fail at startup under parse::<u64>() and would
    otherwise 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_from away from being unreachable at all.
  • Coin lost block_created and status. get_coins returns unspent coins only, so
    the CoinStatus::Unspent filter in the startup balance is gone rather than silently
    matching nothing.
  • Bech32Address was removed from the SDK. POST /dispense therefore no longer accepts
    fuel1… addresses, only hex/checksum. This is a user-visible behaviour change, it
    arrived 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.
  • Test harness. CoinConfigGenerator moved to fuel_core_chain_config::coin_config_helpers
    (new dev-dependency), CoinConfig::owner is now Owner::SecretKey(..),
    starting_gas_price became GasPriceConfig { starting_exec_gas_price, .. },
    historical_execution has to be set, the GraphQL query-complexity limit has to be raised
    for the 128-request test, and rng.gen() is rng.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 from
max_depth * 10_000 so the pool never orders a child ahead of its parent. Four things in
this upgrade could have broken that silently:

  1. coin-cache is still opt-in and still off. cargo tree -e features -i fuels-accounts
    shows only default, std, fuel-core-client. This matters: with the feature on,
    Provider::submit rejects a transaction that spends a coin it saw recently and
    get_spendable_resources excludes those coins for 30 s — which is exactly the caching Fixes for the testnet faucet #72
    removed because "it breaks the faucet if transaction is lost in the network". The faucet's
    recovery from a lost chain is to drop last_output and ask the node what it really owns;
    that has to keep working.
  2. adjust_for_fee still cannot re-select the coin the chain is spending. When it needs
    to top up, it passes the transaction's existing base-asset inputs as the exclusion list.
  3. add_base_change_if_needed still matches any base-asset Output::Change regardless of
    recipient.
    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 still
    output 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.
  4. available_balance still counts message inputs, so a wallet funded by a bridge message
    rather than a coin still works.

Verified

  • cargo build --locked and cargo 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:

    transactions per block: {4: 8, 5: 7, 6: 8, 7: 8, 8: 8, 9: 8, 10: 7, 11: 8, 12: 2}
    chained links: 63, chain roots: 1, max txs in one block: 8
    
  • cargo fmt --check and cargo clippy --all-features --all-targets -- -D warnings, clean on
    the 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_parameters including the fee parameters the faucet's own max_fee arithmetic
    depends on (gas_per_byte 1, gas_price_factor 1150000), latest_gas_price,
    estimate_gas_price, latest_block_height. Testnet reports nodeVersion 0.48.3, which is
    what this PR pins.

  • The live faucet wallet, through the same call the dispense handler makes.
    0xd205d74d…300e134f holds a single coin of 646_026_729_198, and
    coins_to_spend(dispense_amount * max_depth * 2 = 128_000_000) — literally what
    get_coins() asks for when it seeds a fresh chain — returns one resource. The "one huge
    input" assumption issue Support multiple inputs #54 complains about still holds in production, and the u128 coin
    selection 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 UTXO
    0x036ce91d…0002output index 2 of its predecessor — carries tip: 319987 (the
    fourteenth 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-process
    test above asserts exactly that shape.

  • Log lines the deployment depends on are unchanged: Faucet Account: / Faucet Balance: at
    startup and dispensed {} tokens to {} per dispense. (The filename/line_number fields
    in the JSON logs shift because src/routes.rs grew; 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@v1 installs
RUST_VERSION but never activates it. The clippy job's own log says
rustup toolchain install 1.93.01.93.0-x86_64-unknown-linux-gnu installed, and then
rustc -V prints rustc 1.98.0 (88d9e12ae 2026-08-18). Every Rust job in this workflow
runs on whatever stable the runner image ships; RUST_VERSION only really binds the
Dockerfile base image.

Two visible consequences:

  • Clippy's lint surface drifts with the runner image, not with the repo.
    useless_format in recaptcha::verify is original code that passed in April and fails
    today. It is fixed here, together with two useless_borrows_in_formatting in
    tests/dispense.rs that 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.
  • The ethnum bump is load-bearing, not cosmetic. With the runner on 1.98.0 this branch
    would not have compiled at all without it, which is also why cargo-check and cargo-test
    are green here.

Fixing the workflow properly (dtolnay/rust-toolchain, or a rust-toolchain.toml) needs a
token with workflow scope, so it is left for a follow-up.

Not verified

  • No dispense against real testnet. That needs the funded faucet key, which lives in the
    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.
  • The container image was built by CI for both architectures and the manifest merged, but
    neither image has been run — only built.
  • No metrics were added, so the "Faucet Empty" Oodle monitor still queries a faucet metric
    family the binary does not export. Out of scope here — flagged, not touched.

Follow-ups (not in this PR)

  1. Merging this is not shipping. After merge, tag v0.8.0 and repin
    fuel-deployment-v2kustomize/environments/fuel-prod-testnet-hybrid/fuel-apps/faucet/release.yaml
    from image.tag: "cfee2e4" to the semver tag, so production stops running an unmerged
    branch build.
  2. There is a rebuildable image again in the meantime. CI published
    ghcr.io/fuellabs/faucet:845e9b2 — this head merged with current master, as a genuine
    amd64 + 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 a
    live risk while this sits open.
  3. The deployment's kubernetes.io/arch: amd64 node affinity exists because the image used to
    be amd64-only. The manifest above carries arm64, so it can go.
  4. Make the CI toolchain pin real. actions-rs/toolchain@v1 is deprecated and does not
    activate what it installs, so every Rust job silently follows the runner image.
  5. README.md still documents MIN_GAS_PRICE, removed when ordering moved to tips, and omits
    DISPENSE_LIMIT_INTERVAL, NUMBER_OF_RETRIES and TIMEOUT_SECONDS. src/constants.rs is
    the authoritative list.
  6. deployment/charts/ is a dead second Helm chart that still advertises
    MAX_DISPENSES_PER_MINUTE, an environment variable the binary has not read in years.

🤖 Generated with Claude Code

@xgreenx xgreenx self-assigned this Apr 18, 2026
@cursor

cursor Bot commented Apr 18, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
CI-only toolchain pin; no application or deployment logic changes in this diff.

Overview
CI toolchain: .github/workflows/ci.yml raises the shared RUST_VERSION env from 1.81.0 to 1.93.0, so every job that installs Rust via ${{ env.RUST_VERSION }} (lint, build, test) runs on the newer stable compiler the rest of the branch expects.

Reviewed by Cursor Bugbot for commit d9a2467. Bugbot is set up for automated code reviews on this repo. Configure here.

@xgreenx
xgreenx enabled auto-merge (squash) April 18, 2026 18:20
@xgreenx
xgreenx disabled auto-merge April 18, 2026 18:21
@xgreenx
xgreenx enabled auto-merge (squash) April 18, 2026 23:45
xgreenx and others added 3 commits August 28, 2026 22:30
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>
@xgreenx xgreenx changed the title Update the code to use fuel-core 0.48.0 Update the code to use fuel-core 0.48.3 Aug 28, 2026
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>
@xgreenx
xgreenx merged commit 4facff3 into master Aug 28, 2026
19 checks passed
@xgreenx
xgreenx deleted the feature/fuel-core-0.48 branch August 28, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants