Skip to content

HIP-150 - #1245

Open
michaeldjeffrey wants to merge 6 commits into
mainfrom
mj/hip-150
Open

HIP-150#1245
michaeldjeffrey wants to merge 6 commits into
mainfrom
mj/hip-150

Conversation

@michaeldjeffrey

Copy link
Copy Markdown
Contributor

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 the bytes_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 table
  • 11_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 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.

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.

ALTER TABLE data_transfer.burned_sessions ADD COLUMN multiplier DECIMAL(9, 6)

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

MPV__MULTIPLIER__INPUT_BUCKET__BUCKET # Where to find data_transfer_multiplier ticket reports.
MPV__MULTIPLIER__START_AFTER          # Start ingesting from...
MPV__MULTIPLIER__AUTHORIZED_KEYS      # Comma separated list of b58 keys that can sign multiplier tickets
MPV__MULTIPLIER__TICKET_MAX_AGE       # How long are tickets valid after being signed?

New Metrics

burned_dc_by_multiplier{payer="", multiplier=""}

…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
bbalser marked this pull request as ready for review August 28, 2026 18:44

/// Validate and normalize a multiplier.
///
/// Normalizing means `1.5`, `1.50` and `1.5e0` all become the same value, so

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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.

3 participants