Skip to content

Commit 304a012

Browse files
committed
feat: retry usdc sweep to allow funds to land post deposit
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 7992a3b commit 304a012

4 files changed

Lines changed: 112 additions & 46 deletions

File tree

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

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,49 +7,68 @@ import com.getcode.opencode.model.accounts.AccountFilter
77
import com.getcode.opencode.model.accounts.AccountType
88
import com.getcode.solana.keys.Mint
99
import com.getcode.solana.keys.base58
10-
import com.getcode.utils.ErrorUtils
1110
import com.getcode.utils.TraceType
11+
import com.getcode.utils.network.retryable
1212
import com.getcode.utils.trace
1313
import kotlinx.coroutines.CoroutineScope
1414
import kotlinx.coroutines.Dispatchers
1515
import kotlinx.coroutines.Job
1616
import kotlinx.coroutines.SupervisorJob
17-
import kotlinx.coroutines.ensureActive
1817
import kotlinx.coroutines.launch
1918
import javax.inject.Inject
19+
import kotlin.time.Duration
20+
import kotlin.time.Duration.Companion.seconds
2021

21-
class UsdcDepositSweep @Inject constructor(
22+
class UsdcDepositSweep(
2223
private val transactionOperations: TransactionOperations,
2324
private val accountController: AccountController,
2425
private val tokenCoordinator: TokenCoordinator,
26+
private val maxRetries: Int = MAX_RETRIES,
27+
private val initialDelay: Duration = INITIAL_DELAY,
28+
private val backoffFactor: Double = BACKOFF_FACTOR,
2529
) {
30+
@Inject constructor(
31+
transactionOperations: TransactionOperations,
32+
accountController: AccountController,
33+
tokenCoordinator: TokenCoordinator,
34+
) : this(
35+
transactionOperations = transactionOperations,
36+
accountController = accountController,
37+
tokenCoordinator = tokenCoordinator,
38+
maxRetries = MAX_RETRIES,
39+
initialDelay = INITIAL_DELAY,
40+
backoffFactor = BACKOFF_FACTOR
41+
)
42+
2643
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
2744
private var activeJob: Job? = null
2845

2946
fun execute(owner: AccountCluster) {
3047
if (activeJob?.isActive == true) return
3148
activeJob = scope.launch {
32-
val usdcAccount = accountController.getAccount(
33-
accountOwner = owner,
34-
requestingOwner = owner,
35-
filter = AccountFilter.MintAddress(Mint.usdc),
36-
).getOrNull()?.takeIf { account ->
37-
account.accountType == AccountType.AssociatedToken
38-
}
49+
val amount = retryable(
50+
maxRetries = maxRetries,
51+
delayDuration = initialDelay,
52+
backoffFactor = backoffFactor,
53+
) {
54+
val usdcAccount = accountController.getAccount(
55+
accountOwner = owner,
56+
requestingOwner = owner,
57+
filter = AccountFilter.MintAddress(Mint.usdc),
58+
).getOrNull()?.takeIf { account ->
59+
account.accountType == AccountType.AssociatedToken
60+
}
3961

40-
usdcAccount?.let {
41-
trace(tag = TAG, message = "USDC ATA found. => ${it.address.base58()}")
42-
} ?: trace(tag = TAG, message = "USDC ATA not found")
43-
44-
val amount = usdcAccount?.balance ?: 0L
45-
if (amount <= 0L) {
46-
trace(tag = TAG, message = "USDC balance <= 0. nothing to sweep")
47-
return@launch
48-
}
62+
usdcAccount?.let {
63+
trace(tag = TAG, message = "USDC ATA found. => ${it.address.base58()}")
64+
} ?: trace(tag = TAG, message = "USDC ATA not found")
4965

50-
coroutineContext.ensureActive()
66+
val balance = usdcAccount?.balance ?: 0L
67+
check(balance > 0L) { "USDC balance <= 0. nothing to sweep" }
68+
balance
69+
} ?: return@launch
5170

52-
trace(tag = TAG, message = "Swapping $amount USDC quarks to USDF", type = TraceType.Process)
71+
trace(tag = TAG, message = "Swapping $amount USDC to USDF", type = TraceType.Process)
5372

5473
transactionOperations.swapUsdc(
5574
owner = owner,
@@ -70,5 +89,8 @@ class UsdcDepositSweep @Inject constructor(
7089

7190
companion object {
7291
private const val TAG = "UsdcDepositSweep"
92+
private const val MAX_RETRIES = 5
93+
private val INITIAL_DELAY = 5.seconds
94+
private const val BACKOFF_FACTOR = 2.0
7395
}
7496
}

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

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import kotlinx.coroutines.test.runTest
1616
import org.junit.After
1717
import org.junit.Before
1818
import org.junit.Test
19+
import kotlin.time.Duration.Companion.milliseconds
1920

2021
@OptIn(ExperimentalCoroutinesApi::class)
2122
class UsdcDepositSweepTest {
@@ -34,6 +35,9 @@ class UsdcDepositSweepTest {
3435
transactionOperations = transactionOperations,
3536
accountController = accountController,
3637
tokenCoordinator = tokenCoordinator,
38+
maxRetries = 3,
39+
initialDelay = 10.milliseconds,
40+
backoffFactor = 1.0,
3741
)
3842
}
3943

@@ -60,56 +64,67 @@ class UsdcDepositSweepTest {
6064
}
6165

6266
@Test
63-
fun `skips swap when USDC account is not found`() = runTest {
67+
fun `gives up after max retries when USDC account is not found`() = runTest {
6468
stubNoUsdcAccount()
6569

6670
sweep.execute(owner)
6771
advanceUntilIdle()
68-
69-
// Give the internal scope time to complete
70-
Thread.sleep(100)
72+
Thread.sleep(200)
7173

7274
coVerify(exactly = 0) {
7375
transactionOperations.swapUsdc(any(), any())
7476
}
7577
}
7678

7779
@Test
78-
fun `skips swap when USDC account type is not AssociatedToken`() = runTest {
80+
fun `gives up after max retries when account type is not AssociatedToken`() = runTest {
7981
stubUsdcAccount(balance = 1_000_000L, type = AccountType.Primary)
8082

8183
sweep.execute(owner)
8284
advanceUntilIdle()
83-
Thread.sleep(100)
85+
Thread.sleep(200)
8486

8587
coVerify(exactly = 0) {
8688
transactionOperations.swapUsdc(any(), any())
8789
}
8890
}
8991

9092
@Test
91-
fun `skips swap when USDC balance is zero`() = runTest {
93+
fun `gives up after max retries when USDC balance stays zero`() = runTest {
9294
stubUsdcAccount(balance = 0L)
9395

9496
sweep.execute(owner)
9597
advanceUntilIdle()
96-
Thread.sleep(100)
98+
Thread.sleep(200)
9799

98100
coVerify(exactly = 0) {
99101
transactionOperations.swapUsdc(any(), any())
100102
}
101103
}
102104

103105
@Test
104-
fun `skips swap when USDC balance is negative`() = runTest {
105-
stubUsdcAccount(balance = -1L)
106+
fun `retries until balance appears then sweeps`() = runTest {
107+
var callCount = 0
108+
coEvery {
109+
accountController.getAccount(any(), any(), any())
110+
} coAnswers {
111+
callCount++
112+
val balance = if (callCount >= 3) 2_000_000L else 0L
113+
val accountInfo = mockk<AccountInfo> {
114+
every { accountType } returns AccountType.AssociatedToken
115+
every { this@mockk.balance } returns balance
116+
every { address } returns mockk<PublicKey>(relaxed = true)
117+
}
118+
Result.success(accountInfo)
119+
}
120+
coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit)
106121

107122
sweep.execute(owner)
108123
advanceUntilIdle()
109-
Thread.sleep(100)
124+
Thread.sleep(300)
110125

111-
coVerify(exactly = 0) {
112-
transactionOperations.swapUsdc(any(), any())
126+
coVerify {
127+
transactionOperations.swapUsdc(owner, 2_000_000L)
113128
}
114129
}
115130

@@ -166,9 +181,7 @@ class UsdcDepositSweepTest {
166181
Result.success(Unit)
167182
}
168183

169-
// First call starts the job
170184
sweep.execute(owner)
171-
// Second call should be ignored since the first is still active
172185
sweep.execute(owner)
173186

174187
Thread.sleep(700)
@@ -180,7 +193,6 @@ class UsdcDepositSweepTest {
180193

181194
@Test
182195
fun `cancel stops active job`() = runTest {
183-
// Make getAccount slow so we can cancel before swapUsdc is reached
184196
coEvery {
185197
accountController.getAccount(any(), any(), any())
186198
} coAnswers {
@@ -195,7 +207,7 @@ class UsdcDepositSweepTest {
195207
coEvery { transactionOperations.swapUsdc(any(), any()) } returns Result.success(Unit)
196208

197209
sweep.execute(owner)
198-
Thread.sleep(50) // Let the coroutine start
210+
Thread.sleep(50)
199211
sweep.cancel()
200212
Thread.sleep(100)
201213

libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package com.getcode.utils.network
33
import com.getcode.utils.TraceType
44
import com.getcode.utils.trace
55
import kotlinx.coroutines.delay
6+
import kotlin.math.pow
67
import kotlin.time.Duration
78
import kotlin.time.Duration.Companion.seconds
89
import kotlin.time.TimeSource
910

10-
suspend fun <T> retryable(
11+
suspend inline fun <T> retryable(
1112
maxRetries: Int = 3,
1213
delayDuration: Duration = 2.seconds,
14+
backoffFactor: Double = 1.0,
1315
retryIf: (Throwable) -> Boolean = { true },
1416
onRetry: (Int) -> Unit = { currentAttempt ->
1517
trace(
@@ -36,11 +38,6 @@ suspend fun <T> retryable(
3638
call()
3739
} catch (e: Throwable) {
3840
if (!retryIf(e)) throw e
39-
trace(
40-
message = "Attempt $currentAttempt failed with exception: ${e.message}",
41-
error = e,
42-
type = TraceType.Error
43-
)
4441
null
4542
}
4643

@@ -50,7 +47,8 @@ suspend fun <T> retryable(
5047
currentAttempt++
5148
if (currentAttempt < maxRetries) {
5249
onRetry(currentAttempt)
53-
delay(delayDuration.inWholeMilliseconds)
50+
val actualDelay = delayDuration * backoffFactor.pow(currentAttempt - 1)
51+
delay(actualDelay.inWholeMilliseconds)
5452
}
5553
}
5654
}
@@ -66,6 +64,7 @@ suspend fun <T> retryable(
6664
suspend fun <T> retryableOrThrow(
6765
maxRetries: Int = 3,
6866
delayDuration: Duration = 2.seconds,
67+
backoffFactor: Double = 1.0,
6968
retryIf: (Throwable) -> Boolean = { true },
7069
onRetry: (Int) -> Unit = { currentAttempt ->
7170
trace(
@@ -102,7 +101,8 @@ suspend fun <T> retryableOrThrow(
102101
currentAttempt++
103102
if (currentAttempt < maxRetries) {
104103
onRetry(currentAttempt)
105-
delay(delayDuration.inWholeMilliseconds)
104+
val actualDelay = delayDuration * backoffFactor.pow(currentAttempt - 1)
105+
delay(actualDelay.inWholeMilliseconds)
106106
}
107107
}
108108
}

libs/network/connectivity/public/src/test/kotlin/com/getcode/utils/network/RetryTest.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,38 @@ class RetryTest {
7373

7474
// endregion
7575

76+
@Test
77+
fun `retryable with backoffFactor retries and succeeds`() = runTest {
78+
var attempts = 0
79+
val result = retryable(
80+
maxRetries = 4,
81+
delayDuration = 100.milliseconds,
82+
backoffFactor = 2.0,
83+
) {
84+
attempts++
85+
if (attempts < 4) throw RuntimeException("retry")
86+
"ok"
87+
}
88+
assertEquals("ok", result)
89+
assertEquals(4, attempts)
90+
}
91+
92+
@Test
93+
fun `retryable with backoffFactor 1 behaves like fixed delay`() = runTest {
94+
var attempts = 0
95+
val result = retryable(
96+
maxRetries = 3,
97+
delayDuration = 1.milliseconds,
98+
backoffFactor = 1.0,
99+
) {
100+
attempts++
101+
if (attempts < 3) throw RuntimeException("retry")
102+
"ok"
103+
}
104+
assertEquals("ok", result)
105+
assertEquals(3, attempts)
106+
}
107+
76108
// region retryableOrThrow
77109

78110
@Test

0 commit comments

Comments
 (0)