Skip to content

fix(chat): let an exact buy clear a balance-gated group - #1551

Merged
bmc08gt merged 5 commits into
code/cashfrom
fix/chat-gate-post-buy-balance
Sep 24, 2026
Merged

bmc08gt merged 5 commits into
code/cashfrom
fix/chat-gate-post-buy-balance

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Buying exactly the requirement from a balance-gated group ("Buy More" in GroupGateBar) leaves the gate blocked. The wallet shows $5.00, but the balance behind it is a few micro-dollars under $5, and GroupAccess compared raw micro-dollars.

The purchase itself is right: on the USDF path the fee is added on top, so the curve receives the full amount. The shortfall comes from how the client values the result. On the discrete curve, $5 bought from 10k, 50k and 100k supply measures:

Value
Held quarks at the post-buy supply $4.999999 (flooring the quarks)
Same quarks at the pre-buy supply $4.997823 to $4.997968
Old reserve-stream path, 5 ticks $4.999999 falling by 1 µ$ per tick

Five commits:

  • fix(chat): compare at display precision. GroupAccess.evaluate rounds the held balance half-up to cents before comparing, matching what the user sees and iOS's ConversationGate.unmetBalance. $4.995 passes too. The server checks membership against its own supply, so being half a cent generous costs at most a denied join.

  • fix(tokens): re-price held quarks when a supply moves. TokenState keeps one Holding(value, tokenQuarks) per mint, the same pair iOS's StoredBalance keeps, and a reserve tick values the held quarks at the new supply with Fiat.tokenBalance. The old handler converted the USD value to quarks and back, rounding down on both legs. It also snapshotted state and wrote whole maps back, so a fetch that landed mid-tick was lost; it now updates from the current state. balances stays as a derived view, and Fiat is unchanged.

    A fetch writes the supply its metadata carried, even if it is older than one the stream delivered. Neither writer is timestamped, and the fetch is the only source for a mint the stream hasn't reached. The stream's next delivery re-prices the quarks, and the coordinator applies every delivery. This matches iOS's VerifiedProtoService.saveReserveStates (fix(chat): let an exact-amount buy clear a group's balance gate code-ios-app#851). For a first-time mint, whose GetMints supply can predate the buy, adding the mint restarts the stream, and the new stream's opening reserve state re-prices the quarks.

  • fix(chat): sum every holding for a rule that names no mint. An empty mints list was checked against the largest single holding, so $3 each of two tokens failed a $5 bar that iOS admits. It now compares the sum of all holdings, rounded to cents once after adding, as iOS's totalBalance compare does. A rule that names mints is still met by the largest of them.

  • fix(chat): resolve the gate's token for a viewer who holds none. Buy More waits for the rule's currency name, and the chat screen read it only from the token cache. getTokenMetadata caches a network result only for a mint the account holds, so a first-time buyer's button never enabled. The screen now fetches the rule's mint, then follows the cache.

  • fix(chat): keep the gate's Buy More enabled, as iOS does. The button follows iOS's ConversationGatePanel: a rule with no mint, or on USDF, reads "Add Cash" and opens the deposit options; a named mint reads "Buy More" until its name loads, then "Buy More ". Previously a rule with no mint left the button disabled for good.

A balance restored from Room has no quarks and keeps its value until the next fetch.

A $5 buy of a launchpad token from a gated group comes back valued at
$4.999999 or less, and `GroupAccess` compared raw micro-dollars, so the
wallet showed $5.00 while the gate kept blocking until a second buy.

Round the held balance half-up to cents (`Fiat.toDouble`) before comparing
it with the requirement. $4.995 passes too; the server enforces the rule
against its own supply, so a half-cent too generous costs one denied join.
This matches iOS's `ConversationGate.unmetBalance`.
@bmc08gt bmc08gt self-assigned this Sep 24, 2026
@github-actions github-actions Bot added area: network gRPC, connectivity, API, exchange rates area: tokens Token accounts, balances, token info type: fix Bug fix labels Sep 24, 2026
After a buy, the reserve-state stream could not correct a balance valued
at a stale supply, and it lowered an exact one by a micro-dollar per
tick. `TokenState` kept only the USD value, so the handler converted it
to token quarks at the new supply and back through `Fiat.tokenBalance`,
rounding down on both legs. A $5 buy on the discrete curve at 10k-100k
supply read $4.999999, then $4.999998, and so on.

`TokenState` now keeps one `Holding(value, tokenQuarks)` per mint, the
pair iOS's `StoredBalance` keeps, and a tick values the held quarks at
the new supply. `balances` stays as a view derived from the holdings. A
speculative add or subtract writes a holding with no quarks, so stale
quarks cannot outlive it. The handler also updates from the current
state instead of writing back a snapshot, which lost a fetch that landed
mid-tick.

A fetch writes the supply its metadata carried, as iOS's
`VerifiedProtoService.saveReserveStates` does: neither writer is
timestamped, the fetch is the only source for a mint the stream has not
reached, and the stream's next delivery re-prices the quarks.

A balance restored from Room has no quarks and keeps its value until the
next fetch.
A minimum-balance rule with an empty `mints` list was checked against the
largest single holding, so $3 each of two tokens failed a $5 bar. iOS's
`ConversationGate.unmetBalance` compares `totalBalance` for the same rule
and admits that holder. Android now sums every holding and rounds the
total to cents once, after adding, as iOS does. A rule that names mints is
still met by the largest of them.
@bmc08gt
bmc08gt force-pushed the fix/chat-gate-post-buy-balance branch from 6e5d2d5 to f2539b9 Compare September 24, 2026 16:27
Buy More stayed disabled for anyone who held none of a gated group's
token. The button waits for the rule's currency name, and the chat
screen only read it from the token cache. getTokenMetadata writes a
network result to that cache only when the account holds the mint, so
for a first-time buyer the name never arrived.

The chat screen now fetches the rule's mint and then follows the cache,
so a copy hydrated after a buy still replaces the fetched one. The gate
bar's comment no longer says the button lacks a mint to open; the mint
comes from the rule, and only the label waits.
The gate bar disabled its button until the rule's currency name loaded,
and a rule that names no mint never loads one, so its button stayed
dead. iOS's ConversationGatePanel never disables it; it picks the label
and destination from the rule's mint instead.

The button now follows the same table. A rule with no mint, or one on
USDF, reads "Add Cash" and opens the deposit options the chat screen
already presents for an empty balance. A named mint reads "Buy More"
until its name loads, then "Buy More <name>", and opens that token
either way, since the mint comes from the rule.
@bmc08gt
bmc08gt merged commit 0fe2740 into code/cash Sep 24, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates area: tokens Token accounts, balances, token info type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant