HIP-150 - #1245
Open
michaeldjeffrey wants to merge 6 commits into
Open
Conversation
…1241) Nova Labs contributes its Service Provider Rewards to the Deployer Data Reward Pool, moving the Mobile data bucket from 70% to 94% of the Mobile sub-DAO slice and the Service Provider allocation to zero. Set SERVICE_PROVIDER_PERCENT to 0. `emissions_split::split` already makes data transfer the residual of `hnt_rewards_issued`, so the whole issued amount now flows to the data pool with no change to the split mechanism — the `service_provider + data_transfer == floor(hnt_rewards_issued)` invariant and its proptests hold untouched. `reward_service_providers` writes no reward at all rather than one with `amount: 0`, so consumers see no service-provider rows for a suspended epoch rather than rows claiming an award of nothing. Guarded on the amount rather than deleted: the contribution runs to 2027-07-31 and may be extended once by a year, so this is a suspension and not a retirement. Restoring the allocation is SERVICE_PROVIDER_PERCENT alone. The two service-provider integration tests took a PgPool they never used, which made them require a live Postgres; they exercise the pool split and the file sink only, so they move to #[tokio::test]. Decision 2 (direct minting, 80% target, and the on-chain bucket move) is handled upstream and must deploy in the same window as this change.
* HIP-150: accept data transfer multiplier tickets in ingest Adds submit_data_transfer_multiplier_ticket. A ticket grants one hotspot a multiplier on the data credits derived from its rewardable bytes. Ingest verifies the signer and the timestamp, then persists the ticket verbatim. It does not parse the multiplier — the packet verifier decides whether one is acceptable and records that verdict in a verified report, so a rejection stays as auditable as a grant. Notes: - Ticket signers get their own allow-list and is_ticket_signer() check, rather than a NetworkKeyRole variant: that enum belongs to the decommissioned mobile-config. A carrier key must not be able to grant a multiplier. - The list may be empty and defaults to empty, since the release ships before any ticket can be issued. Empty rejects every ticket and warns at startup. - Tickets older than data_transfer_multiplier_ticket_max_age (default 10 minutes) or dated in the future are refused. Signatures never expire, so without this a captured ticket is replayable forever. - valid_data_transfer_session gained a multiplier field with the proto bump; set to None here, so behaviour is unchanged. Cargo.toml patches helium-proto to proto's mj/hip-150 (helium/proto#483). REVERT BEFORE MERGING mj/hip-150 TO main. * HIP-150: tolerate client clock drift on multiplier tickets A client does not share a clock with ingest, so a ticket signed at what the client believes is "now" can arrive stamped slightly ahead of us. Those were refused as post-dated, which is a confusing failure for an honest client with a drifting clock. Tickets up to MAX_CLOCK_DRIFT (1 minute) in the future are now treated as current. Beyond that they are still refused: post-dating must not buy an attacker a longer replay window than an honest client gets, and a ticket inside the allowance still ages out of the freshness window at the same rate, it just starts a minute earlier. The allowance is a shared constant in file-store-oracles rather than a setting in each service. The packet verifier checks freshness too, and measures a ticket's age against the timestamp ingest stamped on it — so if ingest tolerated drift the verifier did not, every ticket ingest accepted from a fast client would be refused downstream. One value, not two that can be configured apart.
…er (#1243) Reads ticket files from s3, rules on each ticket, writes the verdict to a verified report and to data_transfer.multiplier_ticket_history, and keeps data_transfer.multiplier_ticket_inventory merged out of that history. No behaviour change: nothing applies a multiplier yet. The burn path is untouched, and get_multipliers has no caller outside tests. A ticket can be issued, verified and recorded, and it affects nothing until the burn is wired up in a following change. Ruling here rather than at ingest means a refused ticket is recorded: every ticket produces a verified report and a history row, so the record shows why a hotspot is not multiplied as well as why it is. That includes a multiplier outside HIP-150's 1-to-5 range, which is refused here rather than at decode — the file poller discards records that fail to decode, so a ticket has to survive decoding in order to be refused on the record. Nothing is stored in Postgres. History and inventory: - multiplier_ticket_history is the append-only log of every ticket, refusals included. It answers "what happened". - multiplier_ticket_inventory is one row per hotspot holding what is currently in force, refreshed by a periodic Trino MERGE. Valid tickets only, so a refusal neither takes effect nor revokes the last grant it followed. - The merge follows the shape network-dbt uses for enabled_carriers_inventory over enabled_carriers_history — latest-per-key by a row_number() window, merged on the key — but the SQL is ours, issued from the periodic task. MERGE rather than the iceberg writer because that writer is append-only and cannot update a row in place; hence the inventory table is unpartitioned. - The burn will read the inventory: a session is multiplied by whatever is current when it is accumulated, not by what was in force when the data moved. Reconstructing the latter would be false precision — sessions arrive batched behind an ingest roll, tickets arrive on their own schedule, and burns run hourly, so that boundary is already fuzzy by minutes. The cost is that the refresh interval becomes burn-visible, and that replaying a backlog applies today's multipliers to old data. Ticket handling: - Latest-per-hotspot is ordered on the issuer's signed timestamp, not on arrival. A ticket is a correctly signed message that never expires, so a captured one can be resubmitted after the grant it carries was revoked; ordered by arrival it would win. Ordered by signed timestamp it sorts below the ticket that superseded it, which a replay cannot change without the signing key. - Signer and freshness are checked again even though ingest checked them. The two services are configured separately, and it is this verdict that lands on the record. Freshness is measured against when ingest received the ticket, so replaying a backlog of files does not reject every ticket in it. - Tickets may be stamped up to MAX_CLOCK_DRIFT in the future. A client's clock is not ingest's, and the allowance is a constant shared with ingest so the two cannot be configured to disagree about the same ticket. - The multiplier is stored as decimal(9,6), never a float: values are negotiated per venue and are not always binary-representable. IcebergDecimal bridges serde (the write path) and the Trino trait (the read path), which no existing type spans. - Ticket signers may be empty and default to empty, matching ingest. - The GatewayResolver is shared with the session path rather than rebuilt. Cloning shares the snapshot, so the one refresher keeps both current; a second resolver would load its own copy and never refresh it.
* HIP-150: apply data transfer multipliers to burned data credits
A verified ticket grants a hotspot a multiplier on the data credits its
rewardable bytes convert to.
HIP-150 explicitly calls out that the multiplier is applied to derived
DC count, not rewardable_bytes. So a payer never burns more than the
multiplier earns.
Gateways with no tickets get a default multiplier of 1.
** Choosing which multiplier applies
When we burn, sessions are pulled from the database, and the timestamp
on the row is used to find a multiplier (if one exists) that was
active at the time the data transfer session was processed.
Deciding this on read rather than on arrival allows us to handle a
case where a multiplier ticket may come in _after_ a data transfer
session it should apply to within the hour. A ticket effective at 1:30
might not be processed until 1:55, and a row from 1:40 still needs it.
Rows are now one hotspot per file, keyed (pub_key, payer,
last_timestamp), so each has a single instant to be priced at. Before
this a row covered a whole burn window, and bytes from 1:15 and 1:45
were already summed together with no way to separate them.
The burn then groups by (hotspot, multiplier) and converts bytes_to_dc
once per group.
** Where grants live
The ticket ingestor writes multipliers up to 3 places.
1. Iceberg history table.
2. S3 verified_data_transfer_multiplier_ticket_report.
3. Postgres to join against data_transfer_sessions for burning.
In-flight burns carry their multiplier on the pending rows. The amount is
fixed on chain by then, so the confirm path regroups on the stored value
rather than asking the history again, and the record cannot disagree with
what was charged.
** Also in here
Fixes pending_dc_burn. It was incremented by accumulate and decremented
by the burn, but the two never counted the same way: accumulate sums
bytes per payer and converts once, the burn converts per hotspot, so any
payer with more than one hotspot in a file drifted negative every cycle.
It is now set from the priced total each burn cycle. Same metric name, so
existing alerts carry over, and because it is set before the balance
check a payer that cannot pay keeps a gauge showing its real debt.
Repartitions multiplier_ticket_history to bucket(hotspot_pubkey, 4)
instead of day(received_timestamp). We read this table by hotspot, not
by date. The bucket count can be raised later without rewriting what is
already there.
Adds burned_dc_by_multiplier{payer, multiplier}. Summing it gives back
the burned counter; grouping by multiplier shows how much of the burn is
coming from ticketed hotspots.
** Deploying
Three migrations run on start:
- 10_data_transfer_multipliers.sql :: new table
- 11_data_transfer_session_buckets.sql :: data_transfer_sessions key -> (pub_key, payer, last_timestamp)
- 12_pending_session_multiplier.sql :: pending_data_transfer_sessions: multiplier column, same key change
Both key changes widen an existing key so they cannot collide, but they
take an ACCESS EXCLUSIVE lock and rebuild the index. Deploy just after a
burn, when data_transfer_sessions is smallest.
Rolling the binary back after migration 11 will not work. The previous
release's ON CONFLICT (pub_key, payer) has no matching unique constraint
once the key is widened, and every accumulate fails. Roll forward.
*Run by hand before the release:*
ALTER TABLE data_transfer.burned_sessions
ADD COLUMN multiplier DECIMAL(9, 6)
Skipping it is silent. Writes still succeed and the multiplier is
dropped, leaving an audit gap that cannot be backfilled. Burns stay
correct either way.
If multiplier_ticket_history already exists it needs repartitioning too,
since create_table_if_not_exists will not alter an existing table:
ALTER TABLE data_transfer.multiplier_ticket_history
SET PROPERTIES partitioning = ARRAY['bucket(hotspot_pubkey, 4)']
No signer has been provisioned, so it should be empty. Run it with the
deploy, not before: changing the spec under a running writer breaks its
writes until it restarts.
* Update status value stored in iceberg to be simpler
---------
Co-authored-by: Brian Balser <bbalser@nova-labs.com>
bbalser
marked this pull request as ready for review
August 28, 2026 18:44
macpie
reviewed
Aug 28, 2026
|
|
||
| /// Validate and normalize a multiplier. | ||
| /// | ||
| /// Normalizing means `1.5`, `1.50` and `1.5e0` all become the same value, so |
Member
There was a problem hiding this comment.
Suggested change
| /// Normalizing means `1.5`, `1.50` and `1.5e0` all become the same value, so | |
| /// Normalizing means `1.5`, `1.50` and `1.5e0` all become the same value (up to `MAX_SCALE`), so |
macpie
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.
In draft until the last PR is merged into this tracking branch.
#1244
Proto changes
Still need to be merged before this branch is merged to main.
helium/proto#483
Service Provider Allocation
The Service Provider allocation is being contributed to the data pool.
Storing multipliers
The history is going to trino, along with s3. The inventory table is stored in postgres. Until we figure out a more effecient way to cleanup with inventory table or move more things into trino, that table has no maintenance process.
Applying multipliers
Data Transfer Sessions are stored indexed by
(pub_key, payer, file_timestamp). This allows us to make sure we apply the multiplier that was active for a given gateway at the time we processed the file.Sessions are collected, and per HIP-150, the multiplier is applied to "data credits derived from that Hotspot's rewardable bytes".
Pay special attention to
group_by_multiplier(), it's the mechanism that let's us avoid paying thebytes_to_dc()penalty per session when there are rewardable bytes right around the single DC threshold. This was previously achieved by accumulating by pubkey into a single row.Think 10 sessions with 10,000 bytes with 1.5x multiplier. Applied per session would come to 10 DC becuause of `ceil`. Grouped, is 1 DC.
Postgres Migrations
Three migrations run on start:
10_data_transfer_multipliers.sql: new table11_data_transfer_session_buckets.sql:data_transfer_sessions p_key -> (pub_key, payer, last_timestamp)12_pending_session_multiplier.sql:pending_data_transfer_sessions: multiplier column, same key changeBoth key changes widen an existing key so they cannot collide, but they take an
ACCESS EXCLUSIVElock and rebuild the index. Deploy just after a burn, whendata_transfer_sessionsis smallest.Rolling the binary back after migration 11 will not work. The previous release's
ON CONFLICT (pub_key, payer)has no matching unique constraint once the key is widened, and every accumulate fails. Roll forward.Iceberg Migrations
Adding multipliers to BurnedDataTransferSessions in Iceberg.
If we don't run this query in iceberg, the multiplier fields will be ignored while we're writing new data transfer sessions.
Iceberg multipliers table
Currently, we're choosing partition this table by buckets on
hotspot_pubkey.Given what the HIP says about locations that are likely to obtain data transfer multipliers, ~<10%. That means we will have a sparse table where partitioning by day spreads out the little information we have into many small files.
New Settings
New Metrics