Skip to content

Commit 3f15070

Browse files
Use a nullable credentialId and isHeadless for headless connect
Replace the empty-string credential sentinel with a null credentialId and a public isHeadless discriminator, matching the released iOS and Flutter SDKs. isConnected is keyed on the contract address, and the single-passkey operations reject a headless connection on a null credential. Unify the two RPC-visibility poll loops into pollUntilVisibleToRpc and the contract-instance lookup into getContractInstanceEntry. Document the auto-fund latency envelope and the headless operating boundary, and update the tests to the nullable model.
1 parent f6085d2 commit 3f15070

7 files changed

Lines changed: 261 additions & 204 deletions

File tree

stellar-sdk/src/commonMain/kotlin/com/soneso/stellar/sdk/smartaccount/oz/OZConstants.kt

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,4 @@ object OZConstants {
8383
* SDK name sent in client identification headers.
8484
*/
8585
const val CLIENT_NAME = "kmp-stellar-sdk"
86-
87-
/**
88-
* Sentinel credential ID written into the connected state by a headless connect
89-
* ([OZWalletOperations.connectToContract]). Empty string is the natural "no credential"
90-
* marker: it is non-null, so it satisfies the non-null credential invariant of
91-
* [OZSmartAccountKit.setConnectedState] and [OZSmartAccountKit.requireConnected], while a
92-
* real WebAuthn credential ID is never empty. The single-passkey submit path compares the
93-
* connected credential against this value to fail loudly when invoked on a headless kit.
94-
*/
95-
internal const val HEADLESS_CREDENTIAL_ID: String = ""
9686
}

stellar-sdk/src/commonMain/kotlin/com/soneso/stellar/sdk/smartaccount/oz/OZSmartAccountEvents.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ sealed class SmartAccountEvent {
6363
* with no passkey credential). Used by backends and autonomous signers that
6464
* operate via the multi-signer / external-signer pipeline.
6565
*
66-
* Distinct from [WalletConnected]: it carries no credential ID, so the empty
67-
* headless sentinel never leaks onto a public event.
66+
* Distinct from [WalletConnected]: a headless connection has no credential
67+
* ID, so this event carries only the contract address.
6868
*
6969
* @property contractId The smart account contract address (C-address)
7070
*/

stellar-sdk/src/commonMain/kotlin/com/soneso/stellar/sdk/smartaccount/oz/OZSmartAccountKit.kt

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -212,18 +212,33 @@ class OZSmartAccountKit private constructor(
212212
/**
213213
* Indicates whether a wallet is currently connected.
214214
*
215-
* A wallet is connected when both the credential ID and contract ID are set.
216-
* This property reflects in-memory state only. After an app restart, call
215+
* A connection is defined by the smart-account contract; the credential is optional and is
216+
* absent for a headless [walletOperations].connectToContract() connection. This property
217+
* reflects in-memory state only. After an app restart, call
217218
* [walletOperations].connectWallet() to restore a saved session.
218219
*/
219220
val isConnected: Boolean
220-
get() = _credentialId != null && _contractId != null
221+
get() = _contractId != null
221222

222223
/**
223-
* The credential ID of the currently connected wallet.
224+
* Indicates whether the current connection is headless: bound to a smart-account contract
225+
* with no passkey credential.
224226
*
225-
* Returns null if no wallet is connected. The credential ID is Base64URL-encoded
226-
* without padding, matching the WebAuthn specification.
227+
* `true` only for a connection established through
228+
* [walletOperations].connectToContract(). Headless connections are operable only through
229+
* the multi-signer / external-signer pipeline; the single-passkey paths reject them.
230+
*/
231+
val isHeadless: Boolean
232+
get() = _contractId != null && _credentialId == null
233+
234+
/**
235+
* The credential ID of the currently connected wallet, when one is present.
236+
*
237+
* Returns null when no wallet is connected, and also for a headless
238+
* [walletOperations].connectToContract() connection, which binds a contract without a
239+
* passkey credential. Use [isHeadless] to tell a headless connection apart from no
240+
* connection. When present, the credential ID is Base64URL-encoded without padding,
241+
* matching the WebAuthn specification.
227242
*/
228243
val credentialId: String?
229244
get() = _credentialId
@@ -248,10 +263,11 @@ class OZSmartAccountKit private constructor(
248263
*
249264
* Thread-safe: This method can be called from any coroutine.
250265
*
251-
* @param credentialId The Base64URL-encoded credential ID
266+
* @param credentialId The Base64URL-encoded credential ID, or null for a headless
267+
* connection bound to the contract alone.
252268
* @param contractId The smart account contract address (C-address)
253269
*/
254-
internal suspend fun setConnectedState(credentialId: String, contractId: String) {
270+
internal suspend fun setConnectedState(credentialId: String?, contractId: String) {
255271
stateLock.withLock {
256272
_credentialId = credentialId
257273
_contractId = contractId
@@ -330,10 +346,11 @@ class OZSmartAccountKit private constructor(
330346
* Requires that a wallet is currently connected, throwing an error if not.
331347
*
332348
* This helper method is used by operations that require an active connection.
333-
* It provides a consistent error message and atomic access to both credential ID
334-
* and contract ID.
349+
* It provides a consistent error message and atomic access to the credential ID
350+
* and contract ID. A connection is defined by the contract alone, so the returned
351+
* credential ID is null for a headless connection.
335352
*
336-
* @return A pair containing the credential ID and contract ID
353+
* @return A pair containing the (nullable) credential ID and the non-null contract ID
337354
* @throws WalletException.NotConnected if no wallet is connected
338355
*
339356
* Example usage in operation modules:
@@ -342,11 +359,11 @@ class OZSmartAccountKit private constructor(
342359
* // Proceed with operation using credentialId and contractId
343360
* ```
344361
*/
345-
internal suspend fun requireConnected(): Pair<String, String> {
362+
internal suspend fun requireConnected(): Pair<String?, String> {
346363
return stateLock.withLock {
347364
val cId = _credentialId
348365
val ctId = _contractId
349-
if (cId == null || ctId == null) {
366+
if (ctId == null) {
350367
throw WalletException.notConnected(
351368
"No wallet connected. Call createWallet() or connectWallet() first."
352369
)

stellar-sdk/src/commonMain/kotlin/com/soneso/stellar/sdk/smartaccount/oz/OZTransactionOperations.kt

Lines changed: 87 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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 errorretry 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

Comments
 (0)