Skip to content

Build wallet transactions via the HPL blockchain-api (Phase 0) - #560

Open
madninja wants to merge 64 commits into
masterfrom
blockchain-api-client
Open

Build wallet transactions via the HPL blockchain-api (Phase 0)#560
madninja wants to merge 64 commits into
masterfrom
blockchain-api-client

Conversation

@madninja

@madninja madninja commented Jul 7, 2026

Copy link
Copy Markdown
Member

Moves Solana transaction construction out of helium-lib and onto the HPL
blockchain-api. Each migrated command posts an action request (wallet pubkey
as an explicit field), receives an unsigned transaction, signs it locally with
the existing keypair/Ledger signer, then submits via POST /transactions and
polls for confirmation.

Net effect is about 3,800 lines removed: the local instruction builders,
priority-fee logic, lookup-table selection and Squads proposal construction all
move server-side.

Added

  • helium_lib::blockchain_api -- a reqwest client for the /api/v1 surface:
    action requests, the response envelopes, submit + status polling, and
    base64/bincode VersionedTransaction helpers. Wire types mirror the published
    @helium/blockchain-api Zod schemas.
  • CommitOpts::commit_via_api -- decode, review-display, simulate, sign
    locally, then submit and poll.
  • A pre-sign review summary (server intent, fee, payer, decoded program IDs) on
    stderr, alongside the existing Ledger blind-sign hash.
  • A verification guard on transfers: the decoded transaction must touch only the
    token, associated-token and compute-budget programs (or only the system
    program, for SOL), must be authorized by this wallet, and must move funds from
    this wallet's account. It fails closed on any unexpected program, any
    non-transfer token instruction, and on any program that arrives via lookup
    table and so cannot be checked.

Migrated commands

transfer (one + multi), dc mint, dc delegate, dc burn, burn, memo,
swap, hotspots update, hotspots transfer, hotspots burn, hotspots rewards (claim, recipient update, and the claim-automation schedule/queue
tree), data-only hotspot issue + onboard, and the Squads v4 proposal lifecycle
(approve, reject, cancel, execute, config change).

Behavior changes

  • --commit semantics. Without it, an interactive run simulates and asks for
    confirmation before submitting; a non-interactive run (piped/CI) simulates and
    stops, broadcasting nothing. Signing happens only once a submit is decided, so
    a Ledger is never touched for a dry run or a declined action.
  • hotspots update is now update iot / update mobile. Mobile gains WiFi
    deployment flags; IoT gains azimuth. The --onboarding flag is gone -- the
    server selects the onboarding endpoint.
  • Fees are server-side. Priority fee, compute budget and lookup tables are
    chosen by the API. --min-priority-fee, --max-priority-fee and
    --skip-preflight are removed.

Removed

  • Delegated DC burn. The API has no equivalent route, so dc burn covers the
    direct burn only.
  • Squads v3 CLI paths and the local proposal builders. Only routes the v4 API
    surface covers remain.

Validation

Checked against the deployed API (my-helium.web.helium.io), which publishes its
OpenAPI spec at /api/v1/spec.json:

  • All 28 request types diffed against the spec's required fields and property
    names.
  • All 29 action endpoints checked for the right response envelope -- the
    transactionData+estimatedSolFee wrapper vs. the bare TransactionData the
    data-credit, Squads and swap routes return.
  • Every enum cross-checked: reward network, device type, WIFI deployment tag,
    Squads permissions and action tags, simulation commitment, batch status.
  • Each migrated endpoint probed live with the exact body its Rust type
    serializes to. Action endpoints only build unsigned transactions, so this
    broadcasts nothing.

That sweep caught one defect, fixed here: the automation setup body sent
cronSchedule where the server requires schedule, which made claim schedule init fail with HTTP 400. A test now pins the wire names for the automation
bodies, and it was mutation-checked -- reintroducing cronSchedule, or letting
the path-parameter wallet leak back into a body, each turns the suite red.

madninja and others added 11 commits July 6, 2026 17:58
Introduce `helium_lib::blockchain_api`, a reqwest client for the HPL
blockchain-api REST surface (`/api/v1`). The service builds Solana
transactions server-side and returns them unsigned and base64-encoded;
this client posts an action request (wallet pubkey as an explicit
field), receives the transactions, and exposes submit + status polling
so a caller can sign locally and broadcast via `POST /transactions`.

Covers the Phase 0 action endpoints (token transfer/multi-transfer, dc
mint/delegate, hotspot claim-rewards/update-rewards-destination/
update-info) plus the shared response envelope, submit/status types, and
base64<->bincode VersionedTransaction helpers. Wire types mirror the
published `@helium/blockchain-api` Zod schemas. Decode/encode preserve
typed error sources via the existing DecodeError/EncodeError.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `Opts::blockchain_api()` (resolves the base URL from
HELIUM_BLOCKCHAIN_API_URL, else the mainnet default; devnet requires the
env var) and `CommitOpts::commit_via_api()`, which decodes the unsigned
transactions returned by an action endpoint, shows them for review,
refreshes the blockhash, signs locally with the existing signer, then
either submits the batch and polls to a terminal status (--commit) or
simulates locally without broadcasting (dry-run).

Hoist `context_err` to module scope so both the local and API paths reuse
the preflight-log enrichment, and add a `CommitResponse::Batch` variant
for multi-transaction batches. Priority-fee flags do not apply on the API
path; fee/compute-budget/LUT are chosen server-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the minimal pre-sign print with `review_lines`/
`display_action_for_review`: for each transaction the blockchain-api
returns, show the server's declared intent (its `description`), the fee
payer and instruction count derived from the decoded transaction, and the
estimated SOL fee for the batch. Printed to stderr so it does not corrupt
the JSON result on stdout. Ledger sources additionally show the
device blind-sign hash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route `transfer one` (and any single-payee `transfer multi`) through the
blockchain-api's tokens/transfer endpoint, then sign and submit via
CommitOpts::commit_via_api. Squads proposals still build locally (Phase
2), and multi-recipient payments stay on the local builder for now: they
can mix tokens within one atomic transaction, which the single-mint
multi-transfer endpoint cannot express.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simplify `transfer` to HNT: drop the token-type argument from `transfer
one` and the "token" key from the multi-payment file. Because every
payment is now HNT (a single mint), route `transfer multi` through the
blockchain-api multi-transfer endpoint alongside single-recipient
transfers.

Behavior change: MOBILE and IOT are no longer transferable via the CLI,
and a multi-payment file containing a "token" key is now rejected
(deny_unknown_fields) rather than honored. Squads proposals still build
locally (Phase 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Not every action endpoint returns the `{transactionData, estimatedSolFee}`
envelope: the data-credit endpoints return the TransactionData bare, with
no fee estimate. Add an `ApiTransactions` trait implemented for both
`ActionResponse` (wrapped, with fee) and `TransactionData` (bare, no fee)
so a caller can sign and submit either shape uniformly, and return
`TransactionData` from `dc_mint`/`dc_delegate`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route the non-Squads paths of `dc mint` and `dc delegate` through the
blockchain-api data-credit endpoints. Make `CommitOpts::commit_via_api`
generic over the `ApiTransactions` trait so it accepts both the wrapped
and bare response envelopes, and show the estimated fee in the review
summary only when the endpoint provides one. Squads proposals still build
locally (Phase 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route `assets rewards recipient update` (and its `hotspots rewards
recipient update` wrapper) through the blockchain-api
update-rewards-destination endpoint. The token selects the lazy
distributor (via ClaimableToken::lazy_distributor_key) and the entity key
is sent as the hotspot pubkey for server-side asset resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split `hotspots update` into `iot` and `mobile` subcommands mapping to the
blockchain-api update-info endpoint. IoT asserts location/gain/elevation;
Mobile asserts location plus WiFi deployment info (antenna, elevation,
azimuth, mechanical/electrical downtilt, serial). Only the fields passed
are changed; the server keeps current on-chain values for the rest.

Add a typed `DeploymentInfo` (WiFi only) to the lib request types; CBRS is
defunct and not supported. Drops the `--onboarding` flag (the server
selects the onboarding endpoint) and the local build path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reflect the HNT-only transfer (drop the token argument and the
multi-payment "token" field) and the `hotspots update iot`/`update mobile`
subcommands. Also drops the stale "max" amount and "memo" multi-payment
fields, which the code does not accept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Route `swap` through the blockchain-api: GET /swap/quote for the quote,
then POST /swap/instructions (quote passed back verbatim) for the unsigned
transaction, signed and submitted via commit_via_api. The quote's
in/out amounts, mints, slippage, and price impact are merged into the
output as before.

Add `SwapQuote` (round-trips verbatim via a flattened catch-all so routePlan
et al. survive the quote->instructions handoff) and `SwapInstructionsRequest`
to the lib, plus a query-param GET helper. Verified the API quote matches a
direct Jupiter quote for HNT->USDC within quote-timing noise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@madninja
madninja force-pushed the blockchain-api-client branch from 14dc9b5 to 019eeb2 Compare July 7, 2026 16:19
madninja and others added 18 commits July 7, 2026 13:59
Add request types and client methods for the new endpoints: token burn,
memo, dc burn (bare TransactionData), hotspot burn, and single-hotspot
claim (entity key as a path parameter). Mirrors the published contracts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the direct (non-Squads) paths of `burn`, `memo`, `dc burn`,
`assets burn`/`hotspots burn`, and `hotspots rewards claim`/`assets claim
one` to the new blockchain-api endpoints.

- dc burn: direct burn goes via the API; the delegated (router+subdao)
  path still builds locally, since the API endpoint covers only direct.
- Squads proposal paths still build locally (Phase 2).

Behavior change: per-hotspot claim now claims the full pending amount; the
optional `amount` (partial-claim) argument is dropped, matching the
API endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds request types and Client methods for the Squads v4 proposal
lifecycle endpoints (approve/reject/cancel/execute, propose config
change) and an optional multisig/memo on the token transfer request for
the transfer endpoint's propose mode. All lifecycle endpoints return the
bare TransactionData.
Routes the v4 paths of the squads command tree (approve/reject/cancel,
execute, member add/remove, threshold) and single-recipient transfer
--squads proposals through the new blockchain-api endpoints, keeping the
local resolve/permission-check reads. v3 votes/executes and the atomic
approve --execute bundle stay local (the API is v4-only and can't emit
an atomic approve+execute); multi-recipient transfer proposals also stay
local (multi-transfer has no propose mode).
Adds the optional multisig/memo fields to the token burn, dc burn, dc
delegate, and hotspot burn requests, and a new TransferHotspotRequest +
transfer_hotspot client method, matching the blockchain-api's propose
mode. DC mint intentionally has no multisig field (minting needs a fresh
Pyth price in-tx, which a deferred proposal can't provide).
Routes the --squads branches of token burn, dc burn, dc delegate, and
asset/hotspot burn + transfer through the new propose-mode endpoints
(resolving the target to a multisig PDA locally, then letting the API
build the vault proposal), replacing the local submit_proposal_with
wrap. dc mint --squads and multi-recipient transfer --squads stay local
(no API propose path). Adds a shared resolve_multisig helper.
Removes the delegated-burn path (`dc burn --router --subdao`) and the
helium-lib `burn_delegated`/`burn_delegated_message` builders. The
blockchain-api covers only direct DC burn, so this local-construction
path had no migration target; `dc burn` is now direct (or a Squads
vault proposal) only.

Breaking: removes the public helium-lib `dc::burn_delegated` /
`burn_delegated_message` functions.
- Squads v3 vote/execute now error (v4 only); v3 multisigs stay
  inspectable via `squads inspect` / `list`.
- `dc mint --squads` removed: minting needs a fresh Pyth price in-tx,
  which a deferred proposal can't provide.
- multi-recipient `transfer --squads` removed: multi-transfer has no
  propose endpoint (single-recipient still proposes via the API).
- `squads approve --execute` removed: the API can't emit an atomic
  approve+execute in one transaction.

Single-recipient transfer --squads now resolves the target to its
multisig PDA (matching the other action commands). Deletes the
now-unused local submit_proposal_with / wrap_as_proposal / squads_vault
helpers.
The wallet builds all Squads v4 proposals/votes/execs through the
blockchain-api, and v3 is inspect-only, so the local construction
builders have no callers. Removes them from helium-lib, keeping every
read/inspect/resolve path intact:

- v4: propose_ixs(_with_luts), proposal_approve/reject/cancel_ix,
  vault/config_transaction_create_ix, proposal_create_ix,
  vault/config_transaction_execute_ix, execute_ix, get_time_lock,
  proposal_vote_ix, compile_transaction_message(_with_luts),
  ConfigActionInput, and the ix discriminator constants.
- v3: approve/reject/cancel/execute_transaction_ix + build_execute_accounts.

Kept: PDAs, list_open_proposals, get_proposal_info, all decoders,
resolve/inspect, MemberPermissions, and the shared fetch_account.

Breaking: removes these public helium-lib squads construction functions.
…e API

- assets/hotspots transfer: the direct (non-squads) path now builds via
  the tokens/hotspots transfer endpoint, matching the --squads path.
- rewards recipient init: builds via update-rewards-destination (which
  creates the recipient account if absent), setting the destination to
  the caller's wallet.
The wallet builds every migrated action through the blockchain-api, so
the corresponding local construction builders have no callers. Removes
them, keeping reads/PDAs/decoders and the still-live builders
(hotspot::dataonly issue/onboard, queue, schedule, message/transaction
infra).

Deleted whole modules (only held construction): dc, memo, boosting,
ed25519_instruction. Stripped construction from token, reward, asset,
hotspot, hotspot::dataonly, priority_fee, transaction.

Breaking: removes these public helium-lib functions.
Add request types and Client methods for the blockchain-api claim-automation
surface: setup, fund, close, requeue, add-wallet, add-entity, remove-entity,
and the operator floor top-up. Each posts to the automation REST path and
returns the standard unsigned-transaction envelope for local signing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-api

The claim cron is now built server-side: `schedule init` takes a raw crontab
and funds by claim-cycle count (`--duration`), with `fund`, `wallet`, `one`,
`remove`, `requeue`, and `close` as granular API-backed actions. `queue wallet`
becomes a one-time Tuktuk claim. The `info` subcommands stay local reads over
the kept PDA derivations. The raw `--fund` amount and the third-party target
argument are dropped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claim-cron transactions are built by the blockchain-api, so drop the local
instruction and transaction builders from `schedule` and `queue`. Keep the PDA
derivations and the cron-account read the CLI's `info` commands rely on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`hotspots add` now builds its issue and onboard transactions via the API
instead of locally: the wallet decodes the add-gateway token, calls the issue
and onboard endpoints, and signs the returned transactions. The issue tx comes
back co-signed by the ECC verifier, so commit_via_api gains an ApiSigning mode —
PreserveCosigned fills only the owner's signature slot, keeping the server's
message, blockhash, and co-signature intact; FreshBlockhash is the default for
the single-signer transactions every other command uses. The offline
add-gateway token generation stays local.

Also drop the `hotspots updates` history command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With every command building through the blockchain-api, the local construction
machinery is dead. Delete data-only onboarding, versioned-message assembly,
priority-fee estimation, and the Jupiter and maker-onboarding HTTP clients;
drop TransactionOpts and the CLI's --min/--max-priority-fee flags. Priority
fees, compute budget, and lookup tables are now chosen server-side only.

helium-lib keeps reads, PDA derivations, the blockchain-api client, and the
sign/submit path for the unsigned transactions the API returns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Send the required `commitment` query param on the batch-status GET so
confirmation polling doesn't 400. Truncate over-long error bodies by character
rather than byte, so a multi-byte boundary can't panic on an error path. Carry
the batch id in the poll-timeout error so a caller can check whether the batch
landed after the deadline. Drop an unused private helper and a dead constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Without `--commit`, interactive runs now simulate the built transactions and
prompt before submitting instead of only dry-running; `--commit` submits
without asking, and a non-interactive run without `--commit` stays a safe
no-op so scripts never hang or submit unexpectedly. Signing happens only once
a submit is decided, so a Ledger is never touched for a dry run or a declined
action, and a fresh blockhash is fetched only when actually submitting.
Simulation failures now include the program logs.

Sign-in-place bounds-checks the signature slot so a malformed server response
errors cleanly instead of panicking, and a non-mainnet `--url` without an
explicit blockchain-api URL now warns instead of silently using mainnet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
madninja and others added 2 commits July 9, 2026 09:42
The review line printed the server-supplied description plus fee payer
and instruction count. Add the set of programs each transaction actually
invokes, decoded from the transaction rather than taken from the server,
so an unexpected program is visible even when signing blind (only
`transfer` verifies transaction content semantically).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restoring the token argument made it required, which broke the prior
`transfer one <address> <amount>` form that sent HNT. Default the token
to hnt so that form keeps working; an explicit token still overrides.
Matches the `transfer multi --token` default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread helium-lib/src/blockchain_api/mod.rs Outdated

async fn parse<R: DeserializeOwned>(resp: reqwest::Response) -> Result<R, BlockchainApiError> {
let status = resp.status().as_u16();
if (200..300).contains(&status) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StatusCode::is_success() does this same check.

There's also Response::error_for_status() that automatically puts error status codes into the right side of a Result. That would change how you're formatting the returned errs here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to status.is_success() in fb6e295, thanks. Kept the explicit body read rather than error_for_status() since we want the response body in the error (it carries the API's failure detail, which error_for_status drops).

madninja and others added 9 commits July 9, 2026 12:08
Bring the branch up to date with master (5 commits) so it's mergeable
and CI can run. Conflict resolutions:

- error.rs, transaction.rs, reward.rs: master modified the confirmation
  subsystem (ConfirmationError/SignatureStatus) and reward-claim
  construction that this branch removed when moving transaction building
  and confirmation to the blockchain-api. Kept the removals; master's
  changes were to code that no longer exists here.
- cmd/mod.rs: kept both this branch's DEFAULT_BLOCKCHAIN_API_URL and
  master's resolve_endpoints helper (a location collision, not a logic
  conflict); the client() method already uses resolve_endpoints.

Also fixes a needless borrow in the create_wallet example that master
moved into the build, so clippy -D warnings passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address review feedback: replace the manual `(200..300).contains` check
with `reqwest::StatusCode::is_success`. Keep reading the response body
into the error rather than using `error_for_status`, which would discard
the body that carries the API's failure detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With transaction construction moved to the blockchain-api, these deps
are no longer referenced: spl-memo and solana-system-interface (0 uses),
and solana-transaction-utils (only an unreachable error variant, removed
here). helium-lib still depends on solana-sdk/client, anchor, and the HPL
program crates for decoding, signing, PDA derivation, and on-chain reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
clippy's useless_borrows_in_formatting (in `-D clippy::all`, the hygiene
CI check) flags `&self.base_url` in the cert request format!. Pass it by
value and inline the path variable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The setup endpoint requires `schedule`; the client sent `cronSchedule`, so
`claim schedule init` was rejected with HTTP 400 before reaching the server's
handler. The field also accepts a `daily`/`weekly`/`monthly` preset alongside a
raw clockwork crontab, which the CLI help now says.

The wallet is the URL's path parameter on every automation endpoint, so it no
longer goes in the body as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transfer sections still described the HNT-only interim state: the token
argument, the `--token` batch flag and the supported-token list are all present
in the command, so the README documented an interface the CLI does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two repos outside this workspace build Solana transactions with helium-lib and
cannot move to the blockchain-api.

alembic bundles a hotspot location reset and an asset transfer into a single
transaction, sharing one merkle proof so the pair stays inside the 1232-byte
packet limit. The API builds one action per request, so two calls would produce
two transactions, two proofs, and a window where a hotspot is reset but still
owned. helium-solana-api serves a transaction shape its own consumers depend on.

The instruction, message and signing layers therefore return, gated so the
wallet CLI neither compiles them nor takes the three deps they need. Only what
those consumers call comes back: whole features nothing uses stay deleted,
including delegated DC burn, DC delegate and burn, and the onboarding-server
data-only paths.

`tests/txn_surface.rs` names every path the two repos import, so removing one
fails to compile here instead of surfacing as a broken `cargo update` there.
CI gains an `--all-features` test run and `--all-targets` on clippy, without
which neither the feature-gated code nor that test is built at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compute-budget program was allowlisted by the transfer guard and never
inspected, so a transaction carrying exactly the requested transfer could also
carry an unbounded `SetComputeUnitPrice` and take the wallet's whole SOL balance
as priority fee. The guard's own docstring claimed "moves the requested token
and nothing else" holds, which it did not; the fee shown for review is the
server's `estimatedSolFee` and would not reveal it either.

`commit_via_api` now derives the worst-case prioritization fee from the
compute-budget instructions and refuses above the ceiling the local builder
clamps to, so every command inherits the check rather than only `transfer`. The
review line reports the derived bound alongside the server's estimate.

`priority_fee`'s two bounds move out from behind the `txn` feature so the cap
has one definition shared with the local builder instead of a copy that drifts.

Also from review: `txn` pulled `spl-memo` and `solana-transaction-utils`, which
no restored code references; the surface test asserted only that the default LUT
list was non-empty, which a devnet default passed; and it named `IssueToken` and
`HotspotInfoUpdate` without naming the builder methods both consumers call. The
release build now compiles the feature on both runners.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six guards from review, and a simpler commit flow.

`--commit` is now the single decision point: every run simulates, and the flag
adds submission. The interactive prompt is gone. It fired only on a terminal, so
the same command behaved differently piped, and it read as a third state beside
the flag and the dry run. It was not carrying its weight as a control either:
the label shown for review is the server's own text, and a Ledger shows a
blind-sign hash. What the transaction does is now checked in code.

`assets rewards recipient update` decodes the returned transaction and asserts
the destination and the authorizing owner, reading their account positions from
the shipped IDL. A substituted destination is the costliest thing this API can
return, since it keeps paying after the compromise is found and needs a second
on-chain action to undo, and the review line cannot separate it from a correct
update because both invoke the same program.

The co-signed signing path refuses a transaction anchored to a durable nonce.
That path preserves the server's blockhash so an existing co-signature stays
valid, which is also what a nonce needs, and a nonce-anchored transaction never
expires.

A committed batch is re-checked against the wallet's own RPC, so a reported
success is no longer the submitting service's word alone.

The transfer guard now holds the associated-token program to CreateIdempotent;
plain Create funds an account from the signer at rent cost for any owner, which
the amount comparison cannot see. A `--squads` transfer is held to moving
nothing at the top level, where it previously skipped the guard entirely under a
comment claiming funds could not be redirected without it failing first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
madninja and others added 17 commits August 29, 2026 08:10
`issue_token` and `issue_token_to_add_tx` sign and decode a protobuf
add-gateway envelope. They touch base64 and helium-crypto and build no Solana
transaction, so they sat behind the transaction-construction feature only
because they share a file with the onboarding builders.

Gating the items inside `hotspot::dataonly` rather than the module lets a
consumer that mints tokens and takes its transactions from the blockchain-api
reach them without pulling in local construction at all.

`IssueToken` is served as a response body by helium-solana-api, so its field
names are a cross-repo contract; the surface test now asserts the serialized
shape rather than only that the type resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ction

A caller that signs a transaction it did not build has to decide what the
transaction may contain, and every such caller was arriving at the same
primitives: resolve a program or account and fail closed when it is loaded from
a lookup table, price the compute-budget instructions to bound what the
transaction can cost in SOL, and establish that one wallet is the only required
signer.

`helium_lib::verify` holds those, ungated, since a caller that no longer builds
transactions still has to inspect them. Policy stays with the caller: which
programs are expected, which SPL-token instruction tags, and which accounts must
hold which pubkeys are all questions only the caller can answer.

The wallet's own copies go, along with the compute-unit limit and the
fee arithmetic it carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The transfer guards each carried their own resolution of a program or account
from an instruction, so what a lookup-table-loaded index means for
verifiability was stated seven times across the wallet. It is stated once, in
`helium_lib::verify`, and the guards read it from there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Establishing that a transaction moves exactly the requested amounts to the
requested recipients, out of one wallet's account and on that wallet's
authority, is the check any caller signing a server-built transfer needs. It
lived in the wallet, where a second consumer could not reach it.

`verify::assert_spl_transfers` takes the expected recipients and raw amounts and
compares totals, so a recipient named twice sums rather than depending on
instruction order. The associated-token program is held to CreateIdempotent,
since a plain create funds an account from the signer at rent cost for any
owner, which the totals do not see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s asked

Three commands signed whatever the service built for them. Each now decodes
the transactions first and refuses one that names a different destination:

- an asset transfer must hand the asset to the requested wallet, on this
  wallet's authority, and a Squads proposal must carry no top-level transfer
- a data-credits mint must credit the requested payee and burn this wallet's
  HNT
- initializing a rewards recipient sets the same standing redirect the update
  path sets, and is held to the same check

`verify::find_methods` resolves an Anchor method by discriminator against the
program's shipped IDL, so the account indices these read are the ones the IDL
declares. `assert_programs_within` goes: both callers that wanted a program
allowlist need per-program branching it cannot express.
A quote is handed back verbatim to build the route, and its
`other_amount_threshold` becomes the on-chain minimum output, so it is the
only thing holding execution to a price: a quote reporting a healthy
`price_impact_pct` can still carry a threshold of 1. The mints, mode, input
amount, slippage and that floor are checked before the quote is spent.

The built route is held to invoking the aggregator, the associated-token and
compute-budget programs, and the SPL-token tags that wrap and unwrap SOL. A
signature alone authorizes every transfer, delegation and authority change
the wallet owns.

Both checks live in helium-lib, so this and the swap service that runs
unattended hold a route to one rule rather than two.
An unexpected-instruction refusal named the program but not the call, so a
service that refuses one unattended reports which program it distrusted
without saying what it saw. The leading discriminator byte travels with the
error.
The router key is an instruction argument rather than an account, so a
substituted one resolves to a different escrow: the DC leaves this wallet and
becomes spendable by someone else's router, and nothing here reverses it.

`NamedInstruction::args` decodes a body against the program's IDL, so the
delegation is checked against what was asked for rather than against an
account standing in for it. A proposal is held to carrying no delegation of
its own, as the vault's DC moves inside a Squads instruction this cannot read.
Five commands signed whatever the service built for them. Each now decodes
the transactions first:

- a DC burn destroys the requested amount, from this wallet
- a token burn destroys the requested amount, out of this wallet's account
- an asset burn destroys one asset this wallet owns
- an info update is for a hotspot this wallet owns, asserts the location
  that was asked for, and sets no field that was not
- adding a hotspot issues it to this wallet and onboards it to this wallet
  at the asserted location

Location is compared exactly, through `hotspot::cell_for`, so the guard
derives the cell the update would. Gain and elevation are checked only for
presence: their unit conversion is the service's, and a second copy of its
rounding would refuse honest updates the day it changes. Which asset a burn
destroys is not checked either, as the leaf is named by a merkle proof.

A proposal moves the vault's funds inside a Squads instruction that cannot
be read here, so each command holds one to carrying no such action itself.
Each check answers whether a built transaction carries the action that was
requested, once, naming the accounts and values the caller named. That is a
question about a program's layout, and the IDLs those positions come from
ship in this crate -- so a caller no longer has to know an account index to
hold a transaction to its own intent.

The CLI was the only consumer while the checks lived in its command modules.
The transaction service serves hotspot transfer, data-only issue and onboard
to callers who then sign them, which is the same question one hop further
out, and the swap service already shares the two swap checks.
Both services decoded a builder's response, refused anything but a single
transaction, bounded the fee and checked the sole signer -- the same four
steps, written twice, against two error types. `sole_signable` states them
once: the per-action checks say what a transaction must do, this says what
shape it has to arrive in.
The type describes an update a caller wants and builds nothing: its
accessors read the fields back and `set_geo` names an h3 cell. A service
that asks the blockchain-api for the update still needs to say what the
update is, so the intent belongs outside the construction gate that
`direct_update_instruction` stays behind.
The gate gated one optional dependency, `solana-system-interface`, which
every build already pulls in transitively: the crate graph is 926 packages
with the feature and 926 without it. What it bought was a compile-time fact
that a consumer builds no transactions locally, and the wallet holds that by
calling none of it.

Against that, every consumer carried the flag, fifty-five `cfg` attributes
carried the split, and the surface test carried two halves. The construction
functions are public like the rest.
Signing is what rejects a transaction whose fee payer or co-signer set does
not match the wallet, and it runs only under `--commit`. A dry run therefore
reported success on a transaction that would fail the moment a key was asked
for -- the case the run exists to find.

The check is the same one the swap service and the transaction service apply,
and it sits before the early return: after it, only the path that already
enforces the rule would reach it.
The line a caller reads before authorizing a swap renders each program
through `KnownProgram::name`, and the aggregator was not in that enum -- so
the one program deciding what the swap does appeared as raw base58 beside a
named compute_budget.

Its address now has a single definition, which `verify` re-exports.
The endpoint's `feePayer` defaults to `maker`, which relays the onboarding
server: the maker co-signs and covers the DC, so the wallet is neither the
fee payer nor the only required signature. This client sent no such field and
took that default, and then signs alone -- so every update it built carried a
signature it could not supply.

The field is required rather than defaulted, so a caller states who pays
instead of inheriting a wire default that does not suit it.
`set_geo` derives its cell through `cell_for`, and a decommission resets a
hotspot to 0,0 before transferring it away. Nothing named the resulting cell,
so a change to the derivation would move where those hotspots land with every
test still passing.
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