Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/packages/simplex-desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@hyperbridge/simplex-desktop",
"productName": "Simplex",
"version": "0.18.1",
"version": "0.18.2",
"private": true,
"description": "Native desktop shell for the Simplex intent solver",
"author": "Polytope Labs <hello@polytope.technology>",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 2026-10-15 — BRIDGE balance re-read the moment a top-up is delivered

A delivered BRIDGE top-up now re-reads the filler's Hyperbridge balance before the sync that
confirmed the delivery returns, so the dashboard's reload shows the new balance at once.

The Hyperbridge balance is not part of `BalanceProvider.refresh()`: `trackHyperbridgeBalance`
reads it once at boot and then on the same once-a-minute interval, because the substrate read is
cheap to cache and rarely urgent. That left the dashboard showing the pre-top-up balance for up to
a minute after delivery: the UI reloads balances the moment the top-up stage flips to `delivered`,
but the snapshot's `hyperbridge` field was whatever the last interval tick had read.

`BalanceProvider.refreshHyperbridgeBalance()` is the interval's read, now public and safe to call
any time (a no-op until tracking is initialized; a failed read keeps the last snapshot).
`BridgeTopUp` takes an optional `refreshBridgeBalance` and awaits it — best-effort — where
`readDelivery` transitions the record to `delivered`, wired in `boot.ts` to the balance provider.
Awaiting it inside the sync is what makes the dashboard's follow-up `/api/balances` read fresh
rather than racing a fire-and-forget refresh.
2 changes: 1 addition & 1 deletion sdk/packages/simplex/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperbridge/simplex",
"version": "0.18.1",
"version": "0.18.2",
"license": "Apache-2.0",
"description": "Automated intent filler for the Hyperbridge IntentGateway — run it as a binary or embed it as a library",
"main": "dist/index.js",
Expand Down
2 changes: 2 additions & 0 deletions sdk/packages/simplex/src/core/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions)
solver: runtimeSigner.address as HexString,
substratePublicKey: toHex((await deriveSubstrateKeyPair(config.simplex.substratePrivateKey)).publicKey),
hyperbridge: intentFiller.hyperbridgeConnection,
// Lets the dashboard show the new BRIDGE balance as soon as a top-up is delivered.
refreshBridgeBalance: () => balanceProvider.refreshHyperbridgeBalance(),
loggers: options.loggers,
})
: undefined
Expand Down
49 changes: 30 additions & 19 deletions sdk/packages/simplex/src/services/BalanceProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,33 +397,44 @@ export class BalanceProvider extends EventEmitter {
}
}

private async trackHyperbridgeBalance(): Promise<void> {
const coprocessor = await this.options.hyperbridge!
const keypair = await deriveSubstrateKeyPair(this.options.substratePrivateKey!)
const address = keypair.address
private hyperbridgeAccount?: { coprocessor: IntentsCoprocessor; address: string }

const fetchBalance = async () => {
try {
// Queried over HTTP, so a websocket outage does not stall the balance read; a failed
// request leaves the last snapshot in place and the next tick picks it up.
const api = await coprocessor.queryApi()
const account = (await api.query.system.account(address)) as any
const decimals = (api.registry.chainDecimals as number[])[0] ?? 12
/**
* Re-reads the filler's BRIDGE balance on Hyperbridge, outside the once-a-minute interval:
* a BRIDGE top-up calls it the moment its delivery lands, so the dashboard's reload shows
* the new balance. A failed read leaves the last snapshot in place. No-op while tracking
* is not initialized (no Hyperbridge key).
*/
async refreshHyperbridgeBalance(): Promise<void> {
const hyperbridge = this.hyperbridgeAccount
if (!hyperbridge) return
try {
// Queried over HTTP, so a websocket outage does not stall the balance read; a failed
// request leaves the last snapshot in place and the next tick picks it up.
const api = await hyperbridge.coprocessor.queryApi()
const account = (await api.query.system.account(hyperbridge.address)) as any
const decimals = (api.registry.chainDecimals as number[])[0] ?? 12

const free = Number.parseFloat(formatUnits(BigInt(account.data.free.toString()), decimals))
const reserved = Number.parseFloat(formatUnits(BigInt(account.data.reserved.toString()), decimals))
const free = Number.parseFloat(formatUnits(BigInt(account.data.free.toString()), decimals))
const reserved = Number.parseFloat(formatUnits(BigInt(account.data.reserved.toString()), decimals))

this.snapshot = { ...this.snapshot, hyperbridge: { address, free, reserved } }
} catch (err) {
this.logger.warn({ err }, "Failed to fetch Hyperbridge balance")
}
this.snapshot = { ...this.snapshot, hyperbridge: { address: hyperbridge.address, free, reserved } }
} catch (err) {
this.logger.warn({ err }, "Failed to fetch Hyperbridge balance")
}
}

private async trackHyperbridgeBalance(): Promise<void> {
const coprocessor = await this.options.hyperbridge!
const keypair = await deriveSubstrateKeyPair(this.options.substratePrivateKey!)
const address = keypair.address
this.hyperbridgeAccount = { coprocessor, address }

await fetchBalance()
await this.refreshHyperbridgeBalance()
// stop() may have run while the awaits above were in flight; a timer
// installed now would outlive the provider.
if (this.stopped) return
this.hyperbridgeInterval = setInterval(fetchBalance, this.intervalMs)
this.hyperbridgeInterval = setInterval(() => void this.refreshHyperbridgeBalance(), this.intervalMs)
this.logger.info({ address }, "Hyperbridge balance tracking initialized")
}
}
Expand Down
14 changes: 13 additions & 1 deletion sdk/packages/simplex/src/services/BridgeTopUp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ export interface BridgeTopUpDeps {
substratePublicKey: HexString
/** Coprocessor the order's bids are read from. */
hyperbridge?: Promise<IntentsCoprocessor>
/** Re-reads the filler's BRIDGE balance the moment a delivery lands, for the dashboard. */
refreshBridgeBalance?: () => Promise<void>
loggers?: Parameters<typeof moduleLogger>[0]
}

Expand Down Expand Up @@ -653,7 +655,17 @@ export class BridgeTopUp {
const delivered =
statuses.some((status) => status.status === "DESTINATION") ||
!!(await withTimeout(hyperbridge.queryRequestReceipt(record.deliveryCommitment!), "the request receipt"))
if (delivered && record.stage !== "delivered") await this.update({ stage: "delivered", error: undefined })
if (delivered && record.stage !== "delivered") {
await this.update({ stage: "delivered", error: undefined })
// The balance moves only now. Refresh it before the sync returns, so the dashboard's
// reload shows it; best-effort, the once-a-minute interval catches up regardless.
const refreshBalance = this.deps.refreshBridgeBalance
if (refreshBalance) {
await refreshBalance().catch((err) =>
this.logger.warn({ err }, "Failed to refresh the BRIDGE balance"),
)
}
}
return delivered
}

Expand Down
8 changes: 6 additions & 2 deletions sdk/packages/simplex/src/tests/bridge-top-up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ function sentLog() {
function topUpWith(record: StoredBridgeTopUp, chain: { receipt?: { logs: unknown[] } } = {}) {
const data = new MemoryDataStore()
void data.state.set({ bridgeTopUp: record })
const refreshBridgeBalance = vi.fn(async () => {})
const topUp = new BridgeTopUp({
state: data.state,
clientManager: {
Expand All @@ -189,6 +190,7 @@ function topUpWith(record: StoredBridgeTopUp, chain: { receipt?: { logs: unknown
},
// Base is delegated; BSC still points at an older SolverAccount.
delegation: { isDelegated: async (chain: string) => chain === "EVM-8453" },
refreshBridgeBalance,
} as unknown as BridgeTopUpDeps)
// Hyperbridge already holds the request, as when another relayer delivered it.
;(topUp as unknown as { hyperbridgeChain: Promise<unknown> }).hyperbridgeChain = Promise.resolve({
Expand All @@ -208,7 +210,7 @@ function topUpWith(record: StoredBridgeTopUp, chain: { receipt?: { logs: unknown
"EVM-8453",
Promise.resolve({ isOrderFilled: async () => true }),
)
return { topUp, data }
return { topUp, data, refreshBridgeBalance }
}

describe("BRIDGE top-up persistence", () => {
Expand Down Expand Up @@ -242,7 +244,7 @@ describe("BRIDGE top-up persistence", () => {

it("recovers an order whose placement landed but whose receipt read failed, then delivers it", async () => {
const placeTxHash = `0x${"66".repeat(32)}` as HexString
const { topUp, data } = topUpWith(
const { topUp, data, refreshBridgeBalance } = topUpWith(
stored("failed", { placeTxHash, sessionPrivateKey: `0x${"55".repeat(32)}`, error: "receipt read failed" }),
{ receipt: { logs: [orderPlacedLog()] } },
)
Expand All @@ -267,6 +269,8 @@ describe("BRIDGE top-up persistence", () => {
})
await topUp.current()
await vi.waitFor(async () => expect((await data.state.get()).bridgeTopUp?.stage).toBe("delivered"))
// The dashboard's BRIDGE balance is re-read the moment delivery lands, not a minute later.
expect(refreshBridgeBalance).toHaveBeenCalledTimes(1)
fetchSpy.mockRestore()
const recovered = (await data.state.get()).bridgeTopUp!
expect(recovered.commitment).toMatch(/^0x[0-9a-f]{64}$/)
Expand Down
Loading