Skip to content

Commit f011453

Browse files
committed
fix(tokens): poll for USDF balance after USDC→USDF sweep
After a successful sweep, the on-chain swap is asynchronous so the initial tokenCoordinator.update() returns 0. Use BalancePoller to actively poll Mint.usdf until the balance becomes displayable, replacing the passive 20s poll cycle with a 5s×12 active window. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 4a0dac0 commit f011453

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/UsdcDepositSweep.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.getcode.opencode.controllers.TransactionOperations
55
import com.getcode.opencode.model.accounts.AccountCluster
66
import com.getcode.opencode.model.accounts.AccountFilter
77
import com.getcode.opencode.model.accounts.AccountType
8+
import com.getcode.opencode.model.financial.Fiat
89
import com.getcode.solana.keys.Mint
910
import com.getcode.solana.keys.base58
1011
import com.getcode.utils.TraceType
@@ -23,21 +24,28 @@ class UsdcDepositSweep(
2324
private val transactionOperations: TransactionOperations,
2425
private val accountController: AccountController,
2526
private val tokenCoordinator: TokenCoordinator,
27+
private val balancePoller: BalancePoller,
2628
private val maxRetries: Int = MAX_RETRIES,
2729
private val initialDelay: Duration = INITIAL_DELAY,
2830
private val backoffFactor: Double = BACKOFF_FACTOR,
31+
private val pollInterval: Duration = POLL_INTERVAL,
32+
private val pollMaxAttempts: Int = POLL_MAX_ATTEMPTS,
2933
) {
3034
@Inject constructor(
3135
transactionOperations: TransactionOperations,
3236
accountController: AccountController,
3337
tokenCoordinator: TokenCoordinator,
38+
balancePoller: BalancePoller,
3439
) : this(
3540
transactionOperations = transactionOperations,
3641
accountController = accountController,
3742
tokenCoordinator = tokenCoordinator,
43+
balancePoller = balancePoller,
3844
maxRetries = MAX_RETRIES,
3945
initialDelay = INITIAL_DELAY,
40-
backoffFactor = BACKOFF_FACTOR
46+
backoffFactor = BACKOFF_FACTOR,
47+
pollInterval = POLL_INTERVAL,
48+
pollMaxAttempts = POLL_MAX_ATTEMPTS,
4149
)
4250

4351
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -76,6 +84,19 @@ class UsdcDepositSweep(
7684
).onSuccess {
7785
trace(tag = TAG, message = "USDC→USDF sweep completed")
7886
tokenCoordinator.update()
87+
balancePoller.awaitBalanceChange(
88+
mint = Mint.usdf,
89+
baseline = Fiat.Zero,
90+
predicate = { _, current -> current.hasDisplayableValue },
91+
interval = pollInterval,
92+
maxAttempts = pollMaxAttempts,
93+
).onFailure { error ->
94+
trace(
95+
tag = TAG,
96+
message = "USDF balance poll timed out: ${error.message}",
97+
type = TraceType.Log,
98+
)
99+
}
79100
}.onFailure { error ->
80101
trace(tag = TAG, message = "USDC→USDF sweep failed: ${error.message}", error = error)
81102
}
@@ -92,5 +113,7 @@ class UsdcDepositSweep(
92113
private const val MAX_RETRIES = 5
93114
private val INITIAL_DELAY = 5.seconds
94115
private const val BACKOFF_FACTOR = 2.0
116+
internal val POLL_INTERVAL = 5.seconds
117+
internal const val POLL_MAX_ATTEMPTS = 12
95118
}
96119
}

apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/UsdcDepositSweepTest.kt

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import com.getcode.opencode.controllers.TransactionOperations
55
import com.getcode.opencode.model.accounts.AccountCluster
66
import com.getcode.opencode.model.accounts.AccountInfo
77
import com.getcode.opencode.model.accounts.AccountType
8+
import com.getcode.opencode.model.financial.Fiat
9+
import com.getcode.solana.keys.Mint
810
import com.getcode.solana.keys.PublicKey
911
import io.mockk.coEvery
1012
import io.mockk.coVerify
@@ -24,20 +26,28 @@ class UsdcDepositSweepTest {
2426
private val transactionOperations: TransactionOperations = mockk(relaxed = true)
2527
private val accountController: AccountController = mockk(relaxed = true)
2628
private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true)
29+
private val balancePoller: BalancePoller = mockk(relaxed = true)
2730

2831
private val owner: AccountCluster = mockk(relaxed = true)
2932

3033
private lateinit var sweep: UsdcDepositSweep
3134

3235
@Before
3336
fun setUp() {
37+
coEvery {
38+
balancePoller.awaitBalanceChange(any(), any(), any(), any(), any())
39+
} returns Result.success(Fiat.Zero)
40+
3441
sweep = UsdcDepositSweep(
3542
transactionOperations = transactionOperations,
3643
accountController = accountController,
3744
tokenCoordinator = tokenCoordinator,
45+
balancePoller = balancePoller,
3846
maxRetries = 3,
3947
initialDelay = 10.milliseconds,
4048
backoffFactor = 1.0,
49+
pollInterval = 10.milliseconds,
50+
pollMaxAttempts = 2,
4151
)
4252
}
4353

@@ -144,7 +154,7 @@ class UsdcDepositSweepTest {
144154
}
145155

146156
@Test
147-
fun `calls tokenCoordinator update on successful swap`() = runTest {
157+
fun `calls tokenCoordinator update and polls for USDF balance on successful swap`() = runTest {
148158
stubUsdcAccount(balance = 1_000_000L)
149159
coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit)
150160

@@ -155,6 +165,15 @@ class UsdcDepositSweepTest {
155165
coVerify {
156166
tokenCoordinator.update()
157167
}
168+
coVerify {
169+
balancePoller.awaitBalanceChange(
170+
mint = Mint.usdf,
171+
baseline = Fiat.Zero,
172+
predicate = any(),
173+
interval = 10.milliseconds,
174+
maxAttempts = 2,
175+
)
176+
}
158177
}
159178

160179
@Test
@@ -173,6 +192,42 @@ class UsdcDepositSweepTest {
173192
}
174193
}
175194

195+
@Test
196+
fun `does not poll for USDF balance when swap fails`() = runTest {
197+
stubUsdcAccount(balance = 1_000_000L)
198+
coEvery {
199+
transactionOperations.swapUsdc(any(), any())
200+
} returns Result.failure(RuntimeException("swap failed"))
201+
202+
sweep.execute(owner)
203+
advanceUntilIdle()
204+
Thread.sleep(200)
205+
206+
coVerify(exactly = 0) {
207+
balancePoller.awaitBalanceChange(any(), any(), any(), any(), any())
208+
}
209+
}
210+
211+
@Test
212+
fun `completes gracefully when USDF balance poll times out`() = runTest {
213+
stubUsdcAccount(balance = 1_000_000L)
214+
coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit)
215+
coEvery {
216+
balancePoller.awaitBalanceChange(any(), any(), any(), any(), any())
217+
} returns Result.failure(BalancePollError.Timeout(Mint.usdf, 2))
218+
219+
sweep.execute(owner)
220+
advanceUntilIdle()
221+
Thread.sleep(200)
222+
223+
coVerify {
224+
tokenCoordinator.update()
225+
}
226+
coVerify {
227+
balancePoller.awaitBalanceChange(any(), any(), any(), any(), any())
228+
}
229+
}
230+
176231
@Test
177232
fun `does not execute concurrently when job is active`() = runTest {
178233
stubUsdcAccount(balance = 1_000_000L)

0 commit comments

Comments
 (0)