@@ -445,6 +445,8 @@ class OZTransactionOperations internal constructor(
445445 * bypass auto-resolution entirely.
446446 * @return TransactionResult indicating success or failure
447447 * @throws WalletException.NotConnected if no wallet is connected
448+ * @throws WalletException.HeadlessConnection if the kit is connected headlessly (no passkey
449+ * credential); use the multi-signer / external-signer pipeline instead
448450 * @throws ValidationException if configuration is invalid
449451 * @throws TransactionException if simulation, signing, or submission fails
450452 * @throws WebAuthnException if biometric authentication fails
@@ -461,11 +463,12 @@ class OZTransactionOperations internal constructor(
461463 // STEP 1: Require connected wallet
462464 val (credentialId, contractId) = kit.requireConnected()
463465
464- // Reject the single-passkey path on a headless connection: the empty sentinel
465- // credential decodes silently and would otherwise fail late with no valid signature.
466- // A headless kit must operate via the multi-signer / external-signer pipeline with
467- // explicit non-empty selectedSigners.
468- if (credentialId == OZConstants .HEADLESS_CREDENTIAL_ID ) {
466+ // Reject the single-passkey path on a headless connection: a headless kit holds no
467+ // passkey credential, so it cannot produce a WebAuthn signature and must operate via
468+ // the multi-signer / external-signer pipeline with explicit non-empty selectedSigners.
469+ // The null check fires early and smart-casts credentialId to non-null for the rest of
470+ // the path.
471+ if (credentialId == null ) {
469472 throw WalletException .headlessConnection()
470473 }
471474
@@ -749,6 +752,16 @@ class OZTransactionOperations internal constructor(
749752 *
750753 * When no relayer is configured, submits directly via RPC with temp keypair signature.
751754 *
755+ * ## Latency
756+ *
757+ * After Friendbot funding, the call waits for the temporary account to become visible to
758+ * the Soroban RPC before simulating the balance read. That wait is a single poll bounded by
759+ * [OZConstants.RPC_VISIBILITY_TIMEOUT_SECONDS] (45s); when testnet propagation is slow the
760+ * worst-case added latency approaches that budget before the call either proceeds or raises
761+ * [TransactionException.Timeout]. Invoked from `createWallet(autoFund = true)` this poll runs
762+ * after the deploy contract-visibility poll, so the two budgets add up to roughly 90s in the
763+ * worst case. The common case returns within a few seconds.
764+ *
752765 * ## Source Account Auth Conversion
753766 *
754767 * The funding flow converts source_account (Void) credentials to Address credentials
@@ -957,8 +970,18 @@ class OZTransactionOperations internal constructor(
957970 * @throws TransactionException.Timeout if the account is not visible within the budget
958971 */
959972 private suspend fun waitForAccountVisibleToRpc (accountId : String ) {
960- pollUntilAccountVisibleToRpc(accountId) { id ->
961- kit.sorobanServer.getAccount(id)
973+ pollUntilVisibleToRpc(
974+ timeoutMessage = fundingAccountNotVisibleMessage(accountId)
975+ ) {
976+ // The funding account's not-yet-visible signal is an AccountNotFoundException from
977+ // getAccount; map it to "not visible" so the poll keeps waiting. Any other error
978+ // propagates as a transient failure for the helper to retry.
979+ try {
980+ kit.sorobanServer.getAccount(accountId)
981+ true
982+ } catch (_: AccountNotFoundException ) {
983+ false
984+ }
962985 }
963986 }
964987
@@ -1457,67 +1480,84 @@ class OZTransactionOperations internal constructor(
14571480}
14581481
14591482/* *
1460- * Polls [lookup] until the account is visible to the Soroban RPC, the timeout elapses,
1461- * or the coroutine is cancelled.
1483+ * Builds the timeout detail message for the Friendbot funding-account visibility poll.
14621484 *
1463- * Used by [OZTransactionOperations.fundWallet] to bridge the gap between Friendbot
1464- * confirming a funding transaction on Horizon and the Soroban RPC reflecting the new
1465- * account entry in its simulation state. Polling avoids assuming a fixed propagation
1466- * delay, which fails when testnet propagation is slower than the assumed wait.
1485+ * Shared by [OZTransactionOperations.waitForAccountVisibleToRpc] and the poll unit tests so the
1486+ * production wording (which names the account, explains the visibility failure, and advises
1487+ * retrying) has a single ASCII-only source.
14671488 *
1468- * [lookup] performs a single RPC account fetch for the supplied account ID. It must:
1469- * - return normally once the account entry is visible to the RPC;
1470- * - throw [AccountNotFoundException] while the account is not yet visible — the expected
1471- * pre-propagation state, which is swallowed so polling continues;
1472- * - throw any other exception for a transient RPC or transport error, which is retried
1473- * until the deadline and surfaced as the timeout cause.
1489+ * @param accountId The funding account ID (G-address) the poll waited on.
1490+ */
1491+ internal fun fundingAccountNotVisibleMessage (accountId : String ): String =
1492+ " Funding account $accountId not visible to the Soroban RPC within " +
1493+ " ${OZConstants .RPC_VISIBILITY_TIMEOUT_SECONDS } s after Friendbot funding; " +
1494+ " testnet propagation may be delayed. Retry shortly"
1495+
1496+ /* *
1497+ * Polls [probe] until it reports the target is visible to the Soroban RPC, the timeout
1498+ * elapses, or the coroutine is cancelled.
1499+ *
1500+ * Bridges the gap between an off-chain confirmation (Friendbot funding on Horizon, or a deploy
1501+ * transaction included in a ledger) and the Soroban RPC reflecting the new ledger entry in its
1502+ * simulation state. Polling avoids assuming a fixed propagation delay, which fails when testnet
1503+ * propagation is slower than the assumed wait.
1504+ *
1505+ * [probe] performs a single visibility check. It must:
1506+ * - return true once the target entry is visible to the RPC;
1507+ * - return false while the entry is not yet visible (the expected pre-propagation state), which
1508+ * keeps polling without recording a cause;
1509+ * - throw any exception for a transient RPC or transport error, which is retried until the
1510+ * deadline and surfaced as the timeout cause.
14741511 *
1475- * The total wait is bounded by [timeoutSeconds] (covering both lookups and the interval
1476- * sleeps) and is cooperatively cancellable: [delay] and the enclosing timeout both
1477- * observe cancellation, and any [CancellationException] thrown by [lookup] is rethrown
1478- * rather than treated as a transient error.
1512+ * Callers adapt their domain-specific not-yet-visible signal to `false` inside [probe]: an
1513+ * account caller maps the [AccountNotFoundException] thrown by `getAccount`, and a contract
1514+ * caller maps a null `getContractData` result.
14791515 *
1480- * @param accountId The account ID (G-address) to wait for, used in the timeout message
1481- * @param pollIntervalMs Delay between polls in milliseconds
1482- * @param timeoutSeconds Overall budget in seconds before failing
1483- * @param lookup Suspending account lookup with the not-found / transient-error contract above
1484- * @throws TransactionException.Timeout if the account is not visible within the budget
1516+ * The total wait is bounded by [OZConstants.RPC_VISIBILITY_TIMEOUT_SECONDS] (covering both the
1517+ * probes and the [OZConstants.RPC_VISIBILITY_POLL_INTERVAL_MS] interval sleeps) and is
1518+ * cooperatively cancellable: [delay] and the enclosing timeout both observe cancellation, and
1519+ * any [CancellationException] thrown by [probe] is rethrown rather than treated as a transient
1520+ * error.
1521+ *
1522+ * @param timeoutMessage Detail message for the [TransactionException.Timeout] raised when the
1523+ * budget is exhausted.
1524+ * @param probe Suspending visibility check with the visible / not-visible / transient-error
1525+ * contract above.
1526+ * @throws TransactionException.Timeout if the target is not visible within the budget.
14851527 */
1486- internal suspend fun pollUntilAccountVisibleToRpc (
1487- accountId : String ,
1488- pollIntervalMs : Long = OZConstants .RPC_VISIBILITY_POLL_INTERVAL_MS ,
1489- timeoutSeconds : Int = OZConstants .RPC_VISIBILITY_TIMEOUT_SECONDS ,
1490- lookup : suspend (String ) -> Unit
1528+ internal suspend fun pollUntilVisibleToRpc (
1529+ timeoutMessage : String ,
1530+ probe : suspend () -> Boolean
14911531) {
14921532 var lastTransientError: Throwable ? = null
14931533
1494- val completed = withTimeoutOrNull(timeoutSeconds.toLong() * 1000L ) {
1534+ val completed = withTimeoutOrNull(
1535+ OZConstants .RPC_VISIBILITY_TIMEOUT_SECONDS .toLong() * 1000L
1536+ ) {
14951537 while (true ) {
14961538 ensureActive()
14971539 try {
1498- lookup(accountId)
1499- return @withTimeoutOrNull
1500- } catch (_ : AccountNotFoundException ) {
1501- // Account not yet visible to the RPC — the expected state while
1502- // Friendbot funding propagates. Keep polling without recording a cause.
1540+ if (probe()) {
1541+ return @withTimeoutOrNull
1542+ }
1543+ // Not yet visible to the RPC ( the expected pre-propagation state). Keep
1544+ // polling without recording a cause.
15031545 } catch (e: CancellationException ) {
1504- // Cooperative cancellation (including the enclosing timeout) — never
1505- // swallow it as a transient error.
1546+ // Cooperative cancellation (including the enclosing timeout): never swallow
1547+ // it as a transient error.
15061548 throw e
15071549 } catch (e: Exception ) {
1508- // Transient RPC/transport error — retry until the deadline and surface
1509- // the last failure as the timeout cause.
1550+ // Transient RPC/transport error: retry until the deadline and surface the
1551+ // last failure as the timeout cause.
15101552 lastTransientError = e
15111553 }
1512- delay(pollIntervalMs )
1554+ delay(OZConstants . RPC_VISIBILITY_POLL_INTERVAL_MS )
15131555 }
15141556 }
15151557
15161558 if (completed == null ) {
15171559 throw TransactionException .timeout(
1518- details = " Funding account $accountId not visible to the Soroban RPC within " +
1519- " ${timeoutSeconds} s after Friendbot funding; testnet propagation may be delayed. " +
1520- " Retry shortly" ,
1560+ details = timeoutMessage,
15211561 cause = lastTransientError
15221562 )
15231563 }
0 commit comments