From 3307731716651894270555b678f5a5ea25ee1146 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Fri, 26 Jun 2026 23:05:52 +0200 Subject: [PATCH 1/4] Poll the Soroban RPC for funding-account visibility instead of a fixed delay The smart-account auto-fund path funded a throwaway account via Friendbot, then waited a fixed 5 seconds before simulating that account's balance on the Soroban RPC. When testnet propagation exceeds that window the simulation fails with "account entry is missing". Replace the fixed delay with a bounded poll of SorobanServer.getAccount (1.5s interval, 45s timeout, cooperatively cancellable) that proceeds as soon as the account is visible and otherwise fails with a clear timeout error. --- lib/src/smartaccount/oz/oz_constants.dart | 14 + .../oz/oz_transaction_operations.dart | 83 +++++- .../smartaccount/oz/oz_constants_test.dart | 16 +- .../oz_fund_wallet_visibility_poll_test.dart | 256 ++++++++++++++++++ 4 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart diff --git a/lib/src/smartaccount/oz/oz_constants.dart b/lib/src/smartaccount/oz/oz_constants.dart index 2b035491..7867f026 100644 --- a/lib/src/smartaccount/oz/oz_constants.dart +++ b/lib/src/smartaccount/oz/oz_constants.dart @@ -24,6 +24,20 @@ class OZConstants { /// wallet. static const int friendbotReserveXlm = 5; + /// Interval in milliseconds between successive Soroban RPC visibility polls + /// of a freshly Friendbot-funded account. Friendbot returns once Horizon + /// has confirmed the deposit, but the RPC simulation endpoint can lag by + /// one or more ledger closes, so the SDK polls until the account ledger + /// entry is visible to the RPC before simulating against it. + static const int friendbotVisibilityPollIntervalMs = 1500; + + /// Overall timeout in seconds for the Soroban RPC account-visibility poll + /// performed after Friendbot funding. When the funded account does not + /// become visible to the RPC within this budget the funding flow fails + /// with a clear error rather than the opaque "account entry is missing" + /// contract error from simulation. + static const int friendbotVisibilityTimeoutSeconds = 45; + /// Default timeout for transaction submission and polling in seconds. static const int defaultTimeoutSeconds = 30; diff --git a/lib/src/smartaccount/oz/oz_transaction_operations.dart b/lib/src/smartaccount/oz/oz_transaction_operations.dart index 7d222bcc..82f1b347 100644 --- a/lib/src/smartaccount/oz/oz_transaction_operations.dart +++ b/lib/src/smartaccount/oz/oz_transaction_operations.dart @@ -808,7 +808,8 @@ class OZTransactionOperations { /// /// The optional [cancelToken] can be cancelled to abort the operation; /// cancellation is observed before/after each major awaitable step, - /// including the 5-second Friendbot-propagation delay. + /// including the Soroban RPC account-visibility poll that follows + /// Friendbot funding (see [waitForAccountVisibleToRpc]). Future fundWallet({ required String nativeTokenContract, OZSubmissionMethod? forceMethod, @@ -829,14 +830,13 @@ class OZTransactionOperations { throw SmartAccountTransactionException.submissionFailed('Friendbot funding failed'); } - // why: Friendbot returns once Horizon has confirmed the deposit but - // the Soroban RPC simulation endpoint can lag by one ledger close - // (~5s on testnet). Submitting too eagerly results in "account not - // found" failures from simulate. - await _cancellableDelay( - const Duration(milliseconds: 5000), - cancelToken, - ); + // why: Friendbot returns once Horizon has confirmed the deposit but the + // Soroban RPC simulation endpoint can lag behind by one or more ledger + // closes under testnet congestion. Simulating the native SAC balance read + // before the RPC has applied the funding ledger fails with an opaque + // "account entry is missing" contract error, so poll the RPC until it + // sees the account rather than waiting a fixed, optimistic interval. + await waitForAccountVisibleToRpc(tempKeypair.accountId, cancelToken); final tempAccount = await _fetchAccount(tempKeypair.accountId); @@ -1353,6 +1353,71 @@ class OZTransactionOperations { } } + /// Polls the Soroban RPC until the account identified by [accountId] is + /// visible as a classic account ledger entry, then returns. + /// + /// Friendbot returns once Horizon has confirmed the deposit, but the + /// Soroban RPC simulation endpoint can lag behind by one or more ledger + /// closes under testnet congestion. Simulating a contract call that reads + /// the account entry before the RPC has applied the funding ledger fails + /// with an opaque "account entry is missing" contract error, so callers + /// must wait until the RPC sees the account. + /// + /// [SorobanServer.getAccount] queries the RPC's `getLedgerEntries` for the + /// account ledger key and returns `null` while the entry is not yet + /// present; a non-null result is the visibility signal polled here. The + /// poll runs every [pollInterval] up to [timeout]; both default to the + /// [OZConstants.friendbotVisibilityPollIntervalMs] / + /// [OZConstants.friendbotVisibilityTimeoutSeconds] budgets and are + /// overridable so the behaviour can be exercised in tests. Transient RPC + /// errors are retried until the deadline and the most recent one is + /// attached to the timeout error as its cause. + /// + /// [cancelToken] is observed before each poll and during each inter-poll + /// sleep so the caller can abort in flight. Throws + /// [SmartAccountTransactionException.submissionFailed] when the account does + /// not become visible within [timeout]. + @visibleForTesting + Future waitForAccountVisibleToRpc( + String accountId, + dio.CancelToken? cancelToken, { + Duration pollInterval = const Duration( + milliseconds: OZConstants.friendbotVisibilityPollIntervalMs), + Duration timeout = const Duration( + seconds: OZConstants.friendbotVisibilityTimeoutSeconds), + }) async { + final deadline = DateTime.now().add(timeout); + Object? lastError; + while (true) { + _checkCancellation(cancelToken); + try { + final account = await _kit.sorobanServer.getAccount(accountId); + if (account != null) { + return; + } + } catch (e) { + // Any error raised during the lookup is treated as transient and + // retried until the deadline; the most recent one is surfaced on + // timeout. The production caller always passes a valid accountId, so + // in practice these are RPC-level failures (congestion, 5xx). + lastError = e; + } + if (!DateTime.now().isBefore(deadline)) { + break; + } + await _cancellableDelay(pollInterval, cancelToken); + } + final timeoutLabel = timeout.inSeconds >= 1 + ? '${timeout.inSeconds}s' + : '${timeout.inMilliseconds}ms'; + throw SmartAccountTransactionException.submissionFailed( + 'Funding account $accountId not visible to the Soroban RPC within ' + '$timeoutLabel after Friendbot funding; testnet propagation may be ' + 'delayed. Retry shortly.', + cause: lastError, + ); + } + /// Fetches an account from Soroban RPC, throwing /// [SmartAccountTransactionException.submissionFailed] when the account is not found /// or when the RPC call fails. diff --git a/test/unit/smartaccount/oz/oz_constants_test.dart b/test/unit/smartaccount/oz/oz_constants_test.dart index d58a1bf7..e5700f19 100644 --- a/test/unit/smartaccount/oz/oz_constants_test.dart +++ b/test/unit/smartaccount/oz/oz_constants_test.dart @@ -22,6 +22,14 @@ void main() { expect(OZConstants.friendbotReserveXlm, 5); }); + test('test_FRIENDBOT_VISIBILITY_POLL_INTERVAL_MS_equals_1500', () { + expect(OZConstants.friendbotVisibilityPollIntervalMs, 1500); + }); + + test('test_FRIENDBOT_VISIBILITY_TIMEOUT_SECONDS_equals_45', () { + expect(OZConstants.friendbotVisibilityTimeoutSeconds, 45); + }); + test('test_DEFAULT_TIMEOUT_SECONDS_equals_30', () { expect(OZConstants.defaultTimeoutSeconds, 30); }); @@ -48,8 +56,8 @@ void main() { expect(OZConstants.clientName, 'flutter-stellar-sdk'); }); - test('test_OZConstants_exposes_exactly_14_public_constants', () { - // Inventory check: the fourteen values below must all be present and + test('test_OZConstants_exposes_exactly_16_public_constants', () { + // Inventory check: the sixteen values below must all be present and // surfacing the correct constants. If a new constant is added or one is // removed/renamed, this list will fall out of date and force review. final values = [ @@ -57,6 +65,8 @@ void main() { OZConstants.defaultIndexerTimeoutMs, OZConstants.defaultRelayerTimeoutMs, OZConstants.friendbotReserveXlm, + OZConstants.friendbotVisibilityPollIntervalMs, + OZConstants.friendbotVisibilityTimeoutSeconds, OZConstants.defaultTimeoutSeconds, OZConstants.maxSigners, OZConstants.maxPolicies, @@ -68,7 +78,7 @@ void main() { OZConstants.maxIndexerConnectTimeoutMs, OZConstants.maxRelayerConnectTimeoutMs, ]; - expect(values.length, 14); + expect(values.length, 16); expect(values, everyElement(isNotNull)); }); }); diff --git a/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart b/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart new file mode 100644 index 00000000..79d159b4 --- /dev/null +++ b/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart @@ -0,0 +1,256 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +import 'package:dio/dio.dart' as dio; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +import 'oz_pipeline_fixtures.dart'; + +const String _contractA = + 'CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK'; +const String _accountA = + 'GDAT5HWTGIU4TSSZ4752OUC4SABDLTLZFRPZUJ3D6LKBNEPA7V2CIG54'; +const String _credentialId = 'aGVsbG8tc21hcnQtYWNjb3VudA'; + +/// Scriptable [SorobanServer] double whose `getAccount` faithfully returns +/// `null` (the real RPC's "not yet visible" signal) as well as a populated +/// [Account] (visible) or a thrown error (transient RPC failure). +/// +/// The shared [MockSorobanServer] cannot represent `getAccount` returning +/// `null` — its queue holds non-null `Object`s and its default-fallback path +/// raises `StateError` on a `null` fallback — so visibility polling, which +/// hinges on the null-vs-non-null distinction, needs this purpose-built +/// double. +class _ProbeSorobanServer extends SorobanServer { + _ProbeSorobanServer() : super('https://unused.test/'); + + /// Outcomes returned in order: a `null` entry models "account not yet + /// visible", an [Account] models "visible", and an [Exception]/[Error] + /// models a transient RPC failure that is thrown. + final List getAccountOutcomes = []; + + /// Fallback outcome once [getAccountOutcomes] is drained. `null` keeps the + /// account invisible indefinitely. + Object? getAccountFallback; + + /// Records every `getAccount` invocation in order. + final List getAccountCalls = []; + + @override + Future getAccount(String accountId) async { + getAccountCalls.add(accountId); + final Object? outcome = getAccountOutcomes.isNotEmpty + ? getAccountOutcomes.removeAt(0) + : getAccountFallback; + if (outcome is Exception) { + throw outcome; + } + if (outcome is Error) { + throw outcome; + } + return outcome as Account?; + } +} + +void main() { + group('waitForAccountVisibleToRpc', () { + // A short poll interval keeps the visibility-poll tests fast while still + // exercising multiple iterations of the loop. + const fastInterval = Duration(milliseconds: 1); + + test('pollsUntilAccountVisible_thenReturnsWithoutThrowing', () async { + final probe = _ProbeSorobanServer() + ..getAccountOutcomes.addAll([ + null, // not visible yet + null, // still not visible + null, // still not visible + Account(_accountA, BigInt.from(1)), // now visible + ]); + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + await ops.waitForAccountVisibleToRpc( + _accountA, + null, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ); + + // Polled once per null outcome plus the final visible read. + expect(probe.getAccountCalls.length, equals(4)); + expect(probe.getAccountCalls, everyElement(equals(_accountA))); + }); + + test('accountVisibleOnFirstPoll_returnsAfterSingleCall', () async { + final probe = _ProbeSorobanServer() + ..getAccountFallback = Account(_accountA, BigInt.from(7)); + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + await ops.waitForAccountVisibleToRpc( + _accountA, + null, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ); + + expect(probe.getAccountCalls.length, equals(1)); + }); + + test('accountNeverVisible_throwsClearTimeoutError', () async { + final probe = _ProbeSorobanServer()..getAccountFallback = null; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + await expectLater( + () => ops.waitForAccountVisibleToRpc( + _accountA, + null, + pollInterval: fastInterval, + timeout: const Duration(milliseconds: 40), + ), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + contains('not visible to the Soroban RPC'), + ) + .having( + (e) => e.message, + 'message', + contains('Retry shortly'), + ), + ), + ); + + // It actually polled (more than once) before giving up. + expect(probe.getAccountCalls.length, greaterThan(1)); + }); + + test('transientRpcErrors_thenVisible_recoversWithoutThrowing', () async { + final probe = _ProbeSorobanServer() + ..getAccountOutcomes.addAll([ + Exception('rpc 503'), // transient failure, retried + null, // not visible yet + Exception('rpc timeout'), // transient failure, retried + Account(_accountA, BigInt.from(3)), // visible + ]); + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + await ops.waitForAccountVisibleToRpc( + _accountA, + null, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ); + + expect(probe.getAccountCalls.length, equals(4)); + }); + + test('persistentRpcErrors_surfaceLastErrorAsTimeoutCause', () async { + final boom = Exception('rpc permanently unhappy'); + final probe = _ProbeSorobanServer()..getAccountFallback = boom; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + await expectLater( + () => ops.waitForAccountVisibleToRpc( + _accountA, + null, + pollInterval: fastInterval, + timeout: const Duration(milliseconds: 40), + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + same(boom), + ), + ), + ); + }); + + test('alreadyCancelledToken_throwsBeforeAnyPoll', () async { + final probe = _ProbeSorobanServer()..getAccountFallback = null; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + final cancelToken = dio.CancelToken()..cancel(); + + await expectLater( + () => ops.waitForAccountVisibleToRpc( + _accountA, + cancelToken, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Operation cancelled'), + ), + ), + ); + + // Cancellation is observed before the first RPC call is made. + expect(probe.getAccountCalls, isEmpty); + }); + + test('cancelledMidPoll_stopsPollingAndThrows', () async { + // Stays invisible so the loop would otherwise run to the timeout; the + // cancel must abort it well before then. + final probe = _ProbeSorobanServer()..getAccountFallback = null; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZTransactionOperations(kit); + + final cancelToken = dio.CancelToken(); + // Cancel shortly after polling begins. + Future.delayed( + const Duration(milliseconds: 20), + cancelToken.cancel, + ); + + await expectLater( + () => ops.waitForAccountVisibleToRpc( + _accountA, + cancelToken, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Operation cancelled'), + ), + ), + ); + }); + }); + + group('fundWallet pre-poll validation still guards inputs', () { + test('notConnected_throwsBeforeFunding', () async { + final kit = FakePipelineKit(); + final ops = OZTransactionOperations(kit); + await expectLater( + () => ops.fundWallet(nativeTokenContract: _contractA), + throwsA(isA()), + ); + }); + + test('invalidNativeTokenContract_throwsBeforeFunding', () async { + final kit = FakePipelineKit() + ..setConnected(credentialId: _credentialId, contractId: _contractA); + final ops = OZTransactionOperations(kit); + await expectLater( + () => ops.fundWallet(nativeTokenContract: 'garbage'), + throwsA(isA()), + ); + }); + }); +} From c2b04f8c1511652919d2ddf424c5e0ebf78ae0f7 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Fri, 26 Jun 2026 23:52:16 +0200 Subject: [PATCH 2/4] Poll the Soroban RPC for contract-instance visibility instead of a fixed delay After the deploy transaction confirms, the deployed smart-account contract instance may not yet be visible to the Soroban RPC simulation endpoint, so the auto-fund flow waited a fixed 5 seconds before simulating against it. When testnet propagation exceeds that window, funding fails with "account entry is missing". Replace both fixed waits (the createWallet and deployPendingCredential auto-fund paths) with a bounded poll of the contract's instance ledger entry (1.5s interval, 45s timeout, cooperatively cancellable, clear timeout error). The shared visibility constants are renamed from friendbot- to rpc-prefixed since both the account and contract waits use them, and both visibility-wait timeouts now raise the dedicated SmartAccountTransactionTimeout category rather than the submission-failed category. --- lib/src/smartaccount/oz/oz_constants.dart | 27 +- .../oz/oz_transaction_operations.dart | 18 +- .../smartaccount/oz/oz_wallet_operations.dart | 101 +++++- .../smartaccount/oz/oz_constants_test.dart | 12 +- .../oz/oz_contract_visibility_poll_test.dart | 297 ++++++++++++++++++ .../oz_fund_wallet_visibility_poll_test.dart | 4 +- .../oz/oz_wallet_create_pipeline_test.dart | 163 ++++++++++ 7 files changed, 581 insertions(+), 41 deletions(-) create mode 100644 test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart diff --git a/lib/src/smartaccount/oz/oz_constants.dart b/lib/src/smartaccount/oz/oz_constants.dart index 7867f026..1ea9572a 100644 --- a/lib/src/smartaccount/oz/oz_constants.dart +++ b/lib/src/smartaccount/oz/oz_constants.dart @@ -24,19 +24,20 @@ class OZConstants { /// wallet. static const int friendbotReserveXlm = 5; - /// Interval in milliseconds between successive Soroban RPC visibility polls - /// of a freshly Friendbot-funded account. Friendbot returns once Horizon - /// has confirmed the deposit, but the RPC simulation endpoint can lag by - /// one or more ledger closes, so the SDK polls until the account ledger - /// entry is visible to the RPC before simulating against it. - static const int friendbotVisibilityPollIntervalMs = 1500; - - /// Overall timeout in seconds for the Soroban RPC account-visibility poll - /// performed after Friendbot funding. When the funded account does not - /// become visible to the RPC within this budget the funding flow fails - /// with a clear error rather than the opaque "account entry is missing" - /// contract error from simulation. - static const int friendbotVisibilityTimeoutSeconds = 45; + /// Interval in milliseconds between successive Soroban RPC visibility polls. + /// The RPC simulation endpoint can lag the rest of the network by one or + /// more ledger closes, so the SDK polls until a target ledger entry is + /// visible to the RPC before simulating against it. Shared by the + /// post-Friendbot account-entry wait and the post-deploy contract-instance + /// wait. + static const int rpcVisibilityPollIntervalMs = 1500; + + /// Overall timeout in seconds for a Soroban RPC visibility poll. When the + /// target ledger entry (a freshly funded account or a freshly deployed + /// contract instance) does not become visible to the RPC within this budget + /// the flow fails with a clear error rather than the opaque "entry is + /// missing" contract error from simulation. + static const int rpcVisibilityTimeoutSeconds = 45; /// Default timeout for transaction submission and polling in seconds. static const int defaultTimeoutSeconds = 30; diff --git a/lib/src/smartaccount/oz/oz_transaction_operations.dart b/lib/src/smartaccount/oz/oz_transaction_operations.dart index 82f1b347..d2550f30 100644 --- a/lib/src/smartaccount/oz/oz_transaction_operations.dart +++ b/lib/src/smartaccount/oz/oz_transaction_operations.dart @@ -1367,24 +1367,24 @@ class OZTransactionOperations { /// account ledger key and returns `null` while the entry is not yet /// present; a non-null result is the visibility signal polled here. The /// poll runs every [pollInterval] up to [timeout]; both default to the - /// [OZConstants.friendbotVisibilityPollIntervalMs] / - /// [OZConstants.friendbotVisibilityTimeoutSeconds] budgets and are + /// [OZConstants.rpcVisibilityPollIntervalMs] / + /// [OZConstants.rpcVisibilityTimeoutSeconds] budgets and are /// overridable so the behaviour can be exercised in tests. Transient RPC /// errors are retried until the deadline and the most recent one is /// attached to the timeout error as its cause. /// /// [cancelToken] is observed before each poll and during each inter-poll /// sleep so the caller can abort in flight. Throws - /// [SmartAccountTransactionException.submissionFailed] when the account does + /// [SmartAccountTransactionException.timeout] when the account does /// not become visible within [timeout]. @visibleForTesting Future waitForAccountVisibleToRpc( String accountId, dio.CancelToken? cancelToken, { Duration pollInterval = const Duration( - milliseconds: OZConstants.friendbotVisibilityPollIntervalMs), + milliseconds: OZConstants.rpcVisibilityPollIntervalMs), Duration timeout = const Duration( - seconds: OZConstants.friendbotVisibilityTimeoutSeconds), + seconds: OZConstants.rpcVisibilityTimeoutSeconds), }) async { final deadline = DateTime.now().add(timeout); Object? lastError; @@ -1410,10 +1410,10 @@ class OZTransactionOperations { final timeoutLabel = timeout.inSeconds >= 1 ? '${timeout.inSeconds}s' : '${timeout.inMilliseconds}ms'; - throw SmartAccountTransactionException.submissionFailed( - 'Funding account $accountId not visible to the Soroban RPC within ' - '$timeoutLabel after Friendbot funding; testnet propagation may be ' - 'delayed. Retry shortly.', + throw SmartAccountTransactionException.timeout( + details: 'Funding account $accountId not visible to the Soroban RPC ' + 'within $timeoutLabel after Friendbot funding; testnet propagation ' + 'may be delayed. Retry shortly.', cause: lastError, ); } diff --git a/lib/src/smartaccount/oz/oz_wallet_operations.dart b/lib/src/smartaccount/oz/oz_wallet_operations.dart index 09361fd0..717a3719 100644 --- a/lib/src/smartaccount/oz/oz_wallet_operations.dart +++ b/lib/src/smartaccount/oz/oz_wallet_operations.dart @@ -22,6 +22,7 @@ import '../core/smart_account_errors.dart'; import '../core/smart_account_utils.dart'; import '../core/web_authn_provider.dart'; import 'oz_base64url.dart'; +import 'oz_constants.dart'; import 'oz_internal_pipeline_interfaces.dart'; import 'oz_relayer_client.dart'; import 'oz_secure_nonce.dart'; @@ -529,13 +530,13 @@ class OZWalletOperations { 'nativeTokenContract is required when autoFund is true', ); } - // why: Friendbot's HTTP confirmation precedes Soroban RPC state - // visibility by one ledger close (~5s on testnet). Without the delay - // the subsequent fundWallet simulation observes "account not found". - await _cancellableDelay( - const Duration(milliseconds: 5000), - cancelToken, - ); + // why: the deploy transaction confirms once the network applies it, + // but the Soroban RPC simulation endpoint can lag behind by one or + // more ledger closes under testnet congestion. fundWallet simulates + // against the smart-account contract, so wait until the RPC sees the + // deployed contract instance rather than sleeping a fixed, optimistic + // interval that fails when propagation runs long. + await waitForContractVisibleToRpc(contractId, cancelToken); await _kit.transactionOperations.fundWallet( nativeTokenContract: tokenContract, forceMethod: forceMethod, @@ -1015,10 +1016,13 @@ class OZWalletOperations { ); if (autoFund) { - await _cancellableDelay( - const Duration(milliseconds: 5000), - cancelToken, - ); + // why: the deploy transaction confirms once the network applies it, + // but the Soroban RPC simulation endpoint can lag behind by one or + // more ledger closes under testnet congestion. fundWallet simulates + // against the smart-account contract, so wait until the RPC sees the + // deployed contract instance rather than sleeping a fixed, optimistic + // interval that fails when propagation runs long. + await waitForContractVisibleToRpc(contractId, cancelToken); await _kit.transactionOperations.fundWallet( nativeTokenContract: nativeTokenContract!, forceMethod: forceMethod, @@ -1642,6 +1646,81 @@ class OZWalletOperations { return transactionHash; } + // Private: post-deploy contract visibility wait + + /// Polls the Soroban RPC until the deployed smart-account contract instance + /// identified by [contractId] is visible as a persistent contract-data + /// ledger entry, then returns. + /// + /// The deploy transaction confirms once the network applies it, but the + /// Soroban RPC simulation endpoint can lag behind by one or more ledger + /// closes under testnet congestion. Simulating against the contract (as the + /// autoFund flow does via `fundWallet`) before the RPC has applied the deploy + /// ledger fails because the contract instance is not yet in the RPC's view, + /// so callers must wait until the RPC sees the instance entry. + /// + /// [SorobanServer.getContractData] queries the RPC's `getLedgerEntries` for + /// the contract's instance ledger key (the contract-data entry keyed by + /// [XdrSCVal.forLedgerKeyContractInstance] under + /// [XdrContractDataDurability.PERSISTENT]). It returns `null` while the entry + /// is not yet present; a non-null result is the visibility signal polled + /// here. The poll runs every [pollInterval] up to [timeout]; both default to + /// the [OZConstants.rpcVisibilityPollIntervalMs] / + /// [OZConstants.rpcVisibilityTimeoutSeconds] budgets and are overridable so + /// the behaviour can be exercised in tests. Transient RPC errors are retried + /// until the deadline and the most recent one is attached to the timeout + /// error as its cause. + /// + /// [cancelToken] is observed before each poll and during each inter-poll + /// sleep so the caller can abort in flight. Throws + /// [SmartAccountTransactionException.timeout] when the contract does + /// not become visible within [timeout]. + @visibleForTesting + Future waitForContractVisibleToRpc( + String contractId, + dio.CancelToken? cancelToken, { + Duration pollInterval = const Duration( + milliseconds: OZConstants.rpcVisibilityPollIntervalMs), + Duration timeout = const Duration( + seconds: OZConstants.rpcVisibilityTimeoutSeconds), + }) async { + final deadline = DateTime.now().add(timeout); + Object? lastError; + while (true) { + _checkCancellation(cancelToken); + try { + final entry = await _kit.sorobanServer.getContractData( + contractId, + XdrSCVal.forLedgerKeyContractInstance(), + XdrContractDataDurability.PERSISTENT, + ); + if (entry != null) { + return; + } + } catch (e) { + // Any error raised during the lookup is treated as transient and + // retried until the deadline; the most recent one is surfaced on + // timeout. The production caller always passes a freshly-deployed + // contractId, so in practice these are RPC-level failures + // (congestion, 5xx). + lastError = e; + } + if (!DateTime.now().isBefore(deadline)) { + break; + } + await _cancellableDelay(pollInterval, cancelToken); + } + final timeoutLabel = timeout.inSeconds >= 1 + ? '${timeout.inSeconds}s' + : '${timeout.inMilliseconds}ms'; + throw SmartAccountTransactionException.timeout( + details: 'Smart-account contract $contractId not visible to the Soroban ' + 'RPC within $timeoutLabel after deployment; testnet propagation may ' + 'be delayed. Retry shortly.', + cause: lastError, + ); + } + // Private: cancellation plumbing /// Throws a [SmartAccountTransactionException] when [cancelToken] has been diff --git a/test/unit/smartaccount/oz/oz_constants_test.dart b/test/unit/smartaccount/oz/oz_constants_test.dart index e5700f19..87fdfbf9 100644 --- a/test/unit/smartaccount/oz/oz_constants_test.dart +++ b/test/unit/smartaccount/oz/oz_constants_test.dart @@ -22,12 +22,12 @@ void main() { expect(OZConstants.friendbotReserveXlm, 5); }); - test('test_FRIENDBOT_VISIBILITY_POLL_INTERVAL_MS_equals_1500', () { - expect(OZConstants.friendbotVisibilityPollIntervalMs, 1500); + test('test_RPC_VISIBILITY_POLL_INTERVAL_MS_equals_1500', () { + expect(OZConstants.rpcVisibilityPollIntervalMs, 1500); }); - test('test_FRIENDBOT_VISIBILITY_TIMEOUT_SECONDS_equals_45', () { - expect(OZConstants.friendbotVisibilityTimeoutSeconds, 45); + test('test_RPC_VISIBILITY_TIMEOUT_SECONDS_equals_45', () { + expect(OZConstants.rpcVisibilityTimeoutSeconds, 45); }); test('test_DEFAULT_TIMEOUT_SECONDS_equals_30', () { @@ -65,8 +65,8 @@ void main() { OZConstants.defaultIndexerTimeoutMs, OZConstants.defaultRelayerTimeoutMs, OZConstants.friendbotReserveXlm, - OZConstants.friendbotVisibilityPollIntervalMs, - OZConstants.friendbotVisibilityTimeoutSeconds, + OZConstants.rpcVisibilityPollIntervalMs, + OZConstants.rpcVisibilityTimeoutSeconds, OZConstants.defaultTimeoutSeconds, OZConstants.maxSigners, OZConstants.maxPolicies, diff --git a/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart b/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart new file mode 100644 index 00000000..55e97643 --- /dev/null +++ b/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart @@ -0,0 +1,297 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +import 'package:dio/dio.dart' as dio; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +import 'oz_pipeline_fixtures.dart'; + +const String _contractA = + 'CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK'; + +/// Builds a populated [LedgerEntry] standing in for a visible contract +/// instance. Only its non-null presence matters to the poll; the encoded +/// payload is never inspected. +LedgerEntry _visibleInstanceEntry() => LedgerEntry('', '', 0, null, null); + +/// Captured `getContractData` invocation: the contract address, the ledger +/// key ScVal, and the durability tier the poll queried with. +class _ContractDataCall { + const _ContractDataCall(this.contractId, this.key, this.durability); + + final String contractId; + final XdrSCVal key; + final XdrContractDataDurability durability; +} + +/// Scriptable [SorobanServer] double whose `getContractData` faithfully +/// returns `null` (the real RPC's "not yet visible" signal), a populated +/// [LedgerEntry] (visible), or a thrown error (transient RPC failure). +/// +/// The shared [MockSorobanServer] cannot represent `getContractData` returning +/// `null` indefinitely (its fallback path raises `StateError` once the queue +/// drains), so visibility polling, which hinges on the null-vs-non-null +/// distinction over an open-ended number of polls, needs this purpose-built +/// double. +class _ProbeSorobanServer extends SorobanServer { + _ProbeSorobanServer() : super('https://unused.test/'); + + /// Outcomes returned in order: a `null` entry models "contract not yet + /// visible", a [LedgerEntry] models "visible", and an [Exception]/[Error] + /// models a transient RPC failure that is thrown. + final List getContractDataOutcomes = []; + + /// Fallback outcome once [getContractDataOutcomes] is drained. `null` keeps + /// the contract invisible indefinitely. + Object? getContractDataFallback; + + /// Records every `getContractData` invocation in order. + final List<_ContractDataCall> getContractDataCalls = <_ContractDataCall>[]; + + @override + Future getContractData( + String contractId, + XdrSCVal key, + XdrContractDataDurability durability, + ) async { + getContractDataCalls.add(_ContractDataCall(contractId, key, durability)); + final Object? outcome = getContractDataOutcomes.isNotEmpty + ? getContractDataOutcomes.removeAt(0) + : getContractDataFallback; + if (outcome is Exception) { + throw outcome; + } + if (outcome is Error) { + throw outcome; + } + return outcome as LedgerEntry?; + } +} + +void main() { + group('waitForContractVisibleToRpc', () { + // A short poll interval keeps the visibility-poll tests fast while still + // exercising multiple iterations of the loop. + const fastInterval = Duration(milliseconds: 1); + + test('pollsUntilContractVisible_thenReturns_pollingExactlyNPlusOneTimes', + () async { + final probe = _ProbeSorobanServer() + ..getContractDataOutcomes.addAll([ + null, // not visible yet + null, // still not visible + null, // still not visible + _visibleInstanceEntry(), // now visible + ]); + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + await ops.waitForContractVisibleToRpc( + _contractA, + null, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ); + + // Polled once per null outcome (N = 3) plus the final visible read, and + // stopped immediately on the visible read: exactly N + 1 = 4 polls means + // no extra poll/sleep cycle ran after success. + expect(probe.getContractDataCalls.length, equals(4)); + expect( + probe.getContractDataCalls.map((c) => c.contractId), + everyElement(equals(_contractA)), + ); + + // Each poll queried the contract-instance ledger key under persistent + // durability: the structural visibility signal. + final first = probe.getContractDataCalls.first; + expect( + first.key.discriminant, + equals(XdrSCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE), + ); + expect(first.durability, equals(XdrContractDataDurability.PERSISTENT)); + }); + + test('contractVisibleOnFirstPoll_returnsAfterSingleCall_withoutSleeping', + () async { + final probe = _ProbeSorobanServer() + ..getContractDataFallback = _visibleInstanceEntry(); + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + // A deliberately large poll interval: if the helper slept on the visible + // path (before or after the read) the call would hang for ~30s. The tight + // elapsed bound below proves it returned without ever sleeping. + final stopwatch = Stopwatch()..start(); + await ops.waitForContractVisibleToRpc( + _contractA, + null, + pollInterval: const Duration(seconds: 30), + timeout: const Duration(seconds: 60), + ); + stopwatch.stop(); + + expect(probe.getContractDataCalls.length, equals(1)); + expect(stopwatch.elapsed, lessThan(const Duration(seconds: 2))); + }); + + test('contractNeverVisible_throwsClearTimeoutError_withNoCause', () async { + final probe = _ProbeSorobanServer()..getContractDataFallback = null; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + await expectLater( + () => ops.waitForContractVisibleToRpc( + _contractA, + null, + pollInterval: fastInterval, + timeout: const Duration(milliseconds: 40), + ), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + contains('not visible to the Soroban RPC'), + ) + .having( + (e) => e.message, + 'message', + contains('Retry shortly'), + ) + // A pure timeout (no transient errors observed) carries no + // spurious "last error" cause. + .having((e) => e.cause, 'cause', isNull), + ), + ); + + // It actually polled (more than once) before giving up. + expect(probe.getContractDataCalls.length, greaterThan(1)); + }); + + test('transientRpcErrors_thenVisible_recoversWithoutThrowing', () async { + final probe = _ProbeSorobanServer() + ..getContractDataOutcomes.addAll([ + Exception('rpc 503'), // transient failure, retried + null, // not visible yet + Exception('rpc timeout'), // transient failure, retried + _visibleInstanceEntry(), // visible + ]); + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + await ops.waitForContractVisibleToRpc( + _contractA, + null, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ); + + expect(probe.getContractDataCalls.length, equals(4)); + }); + + test('persistentRpcErrors_surfaceLastErrorAsTimeoutCause', () async { + final boom = Exception('rpc permanently unhappy'); + final probe = _ProbeSorobanServer()..getContractDataFallback = boom; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + await expectLater( + () => ops.waitForContractVisibleToRpc( + _contractA, + null, + pollInterval: fastInterval, + timeout: const Duration(milliseconds: 40), + ), + throwsA( + isA().having( + (e) => e.cause, + 'cause', + same(boom), + ), + ), + ); + }); + + test('alreadyCancelledToken_throwsBeforeAnyPoll', () async { + final probe = _ProbeSorobanServer()..getContractDataFallback = null; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + final cancelToken = dio.CancelToken()..cancel(); + + await expectLater( + () => ops.waitForContractVisibleToRpc( + _contractA, + cancelToken, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + contains('Operation cancelled'), + ) + // Cancellation is propagated verbatim, never relabelled as the + // visibility timeout. + .having( + (e) => e.message, + 'message', + isNot(contains('not visible to the Soroban RPC')), + ), + ), + ); + + // Cancellation is observed before the first RPC call is made. + expect(probe.getContractDataCalls, isEmpty); + }); + + test('cancelledMidPoll_propagatesCancellation_notTimeoutOrTransient', + () async { + // Stays invisible so the loop would otherwise run to the timeout; the + // cancel must abort it well before then. + final probe = _ProbeSorobanServer()..getContractDataFallback = null; + final kit = FakePipelineKit(sorobanServer: probe); + final ops = OZWalletOperations(kit); + + final cancelToken = dio.CancelToken(); + // Cancel shortly after polling begins. + Future.delayed( + const Duration(milliseconds: 20), + cancelToken.cancel, + ); + + Object? thrown; + try { + await ops.waitForContractVisibleToRpc( + _contractA, + cancelToken, + pollInterval: fastInterval, + timeout: const Duration(seconds: 30), + ); + fail('expected the cancelled poll to throw'); + } catch (e) { + thrown = e; + } + + // The cancellation surfaces as a submission-failed exception carrying the + // cancellation marker. + expect(thrown, isA()); + final ex = thrown as SmartAccountTransactionSubmissionFailed; + expect(ex.message, contains('Operation cancelled')); + // It was NOT converted into the visibility timeout... + expect(ex.message, isNot(contains('not visible to the Soroban RPC'))); + // ...and the cancellation was NOT recorded as a transient RPC error and + // re-surfaced as the timeout cause: the cause is the cancel error, not a + // retried RPC exception. + expect(ex.cause, same(cancelToken.cancelError)); + + // Polling stopped well before the 30s timeout deadline. + expect(probe.getContractDataCalls.length, greaterThan(0)); + }); + }); +} diff --git a/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart b/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart index 79d159b4..5eb189f4 100644 --- a/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart +++ b/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart @@ -112,7 +112,7 @@ void main() { timeout: const Duration(milliseconds: 40), ), throwsA( - isA() + isA() .having( (e) => e.message, 'message', @@ -165,7 +165,7 @@ void main() { timeout: const Duration(milliseconds: 40), ), throwsA( - isA().having( + isA().having( (e) => e.cause, 'cause', same(boom), diff --git a/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart b/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart index 03605232..e335277f 100644 --- a/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart +++ b/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; +import 'mock_oz_transaction_operations.dart'; import 'oz_pipeline_fixtures.dart'; const String _contractA = @@ -71,6 +72,24 @@ Account _deployerAccount(KeyPair deployer, {int seq = 1}) { return Account(deployer.accountId, BigInt.from(seq)); } +SendTransactionResponse _sendPending({required String hash}) { + final r = SendTransactionResponse({}); + r.hash = hash; + r.status = SendTransactionResponse.STATUS_PENDING; + return r; +} + +GetTransactionResponse _txSuccess({int ledger = 12345}) { + final r = GetTransactionResponse({}); + r.status = GetTransactionResponse.STATUS_SUCCESS; + r.ledger = ledger; + return r; +} + +/// A populated [LedgerEntry] standing in for a visible contract instance; only +/// its non-null presence matters to the visibility poll. +LedgerEntry _visibleInstanceEntry() => LedgerEntry('', '', 0, null, null); + void main() { group('OZWalletOperations.createWallet pipeline', () { test('createWallet_noAutoSubmit_returnsBuildResult', () async { @@ -387,4 +406,148 @@ void main() { ); }); }); + + group('OZWalletOperations.createWallet autoFund wiring', () { + test('autoFund_pollsContractInstanceVisibility_beforeFunding', () async { + // Drives the full createWallet autoSubmit + autoFund happy path and + // proves the deploy/fund flow waits for the deployed contract instance to + // become visible to the Soroban RPC before invoking fundWallet. fundWallet + // itself is stubbed via MockOZTransactionOperations so the assertion + // isolates the visibility poll wiring. + final deployer = KeyPair.random(); + final mock = MockSorobanServer(); + final provider = RecordingWebAuthnProvider(); + final credIdBytes = base64Url.decode( + base64Url.normalize(_credentialIdB64), + ); + final pubKey = _validSecp256r1PublicKey(); + + provider.registerResponses.add(WebAuthnRegistrationResult( + credentialId: credIdBytes, + publicKey: pubKey, + attestationObject: _bytes(37, 0xCC), + )); + + // _buildDeployTransaction: getAccount + simulate. + mock.getAccountResponses.add(_deployerAccount(deployer)); + mock.simulateResponses.add(_simResponseEmpty(minResourceFee: 500)); + // _submitDeployTransaction (RPC path): send pending, then confirm. + mock.sendResponses.add(_sendPending(hash: 'deploy-hash')); + mock.getTransactionResponses.add(_txSuccess(ledger: 4242)); + // waitForContractVisibleToRpc: the contract instance is visible on the + // first poll so the wiring test does not incur a poll-interval sleep. + mock.getContractDataDefault = _visibleInstanceEntry(); + + final config = OZSmartAccountConfig( + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Network.TESTNET.networkPassphrase, + accountWasmHash: '0' * 64, + webauthnVerifierAddress: _contractA, + webauthnProvider: provider, + ); + final kit = FakePipelineKit( + config: config, + sorobanServer: mock, + deployer: deployer, + ); + final mockTxOps = MockOZTransactionOperations(kit); + kit.setTransactionOperations(mockTxOps); + + final ops = OZWalletOperations(kit); + final result = await ops.createWallet( + autoSubmit: true, + autoFund: true, + nativeTokenContract: _contractA, + ); + + // The poll queried the deployed contract's instance ledger key under + // persistent durability before funding. + expect(mock.getContractDataCalls, hasLength(1)); + final probedCall = mock.getContractDataCalls.single; + expect(probedCall.contractId, equals(result.contractId)); + expect( + probedCall.key.discriminant, + equals(XdrSCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE), + ); + expect(probedCall.durability, equals(XdrContractDataDurability.PERSISTENT)); + + // fundWallet was reached exactly once, after the visibility poll resolved. + expect(mockTxOps.fundWalletCalls, hasLength(1)); + expect(mockTxOps.fundWalletCalls.single.nativeTokenContract, + equals(_contractA)); + }); + }); + + group('OZWalletOperations.deployPendingCredential autoFund wiring', () { + test('autoFund_pollsContractInstanceVisibility_beforeFunding', () async { + // Drives the deployPendingCredential autoSubmit + autoFund happy path and + // proves the deploy/fund flow waits for the deployed contract instance to + // become visible to the Soroban RPC before invoking fundWallet. fundWallet + // itself is stubbed via MockOZTransactionOperations so the assertion + // isolates the visibility poll wiring. + final deployer = KeyPair.random(); + final mock = MockSorobanServer(); + final credentialManager = StubCredentialManager(); + + // A pending credential already in local storage (e.g. from a prior + // createWallet(autoSubmit: false)): it carries the smart-account contract + // address and a valid P-256 public key, the two fields + // deployPendingCredential requires before it will build the deploy. + credentialManager.inject(OZStoredCredential( + credentialId: _credentialIdB64, + publicKey: _validSecp256r1PublicKey(), + contractId: _contractA, + )); + + // _buildDeployTransaction: getAccount + simulate. + mock.getAccountResponses.add(_deployerAccount(deployer)); + mock.simulateResponses.add(_simResponseEmpty(minResourceFee: 500)); + // _submitDeployTransaction (RPC path): send pending, then confirm. + mock.sendResponses.add(_sendPending(hash: 'deploy-hash')); + mock.getTransactionResponses.add(_txSuccess(ledger: 4242)); + // waitForContractVisibleToRpc: the contract instance is visible on the + // first poll so the wiring test does not incur a poll-interval sleep. + mock.getContractDataDefault = _visibleInstanceEntry(); + + final config = OZSmartAccountConfig( + rpcUrl: 'https://soroban-testnet.stellar.org', + networkPassphrase: Network.TESTNET.networkPassphrase, + accountWasmHash: '0' * 64, + webauthnVerifierAddress: _contractA, + ); + final kit = FakePipelineKit( + config: config, + sorobanServer: mock, + deployer: deployer, + credentialManager: credentialManager, + ); + final mockTxOps = MockOZTransactionOperations(kit); + kit.setTransactionOperations(mockTxOps); + + final ops = OZWalletOperations(kit); + final result = await ops.deployPendingCredential( + credentialId: _credentialIdB64, + autoSubmit: true, + autoFund: true, + nativeTokenContract: _contractA, + ); + + // The poll queried the deployed contract's instance ledger key under + // persistent durability before funding. + expect(mock.getContractDataCalls, hasLength(1)); + final probedCall = mock.getContractDataCalls.single; + expect(probedCall.contractId, equals(result.contractId)); + expect(probedCall.contractId, equals(_contractA)); + expect( + probedCall.key.discriminant, + equals(XdrSCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE), + ); + expect(probedCall.durability, equals(XdrContractDataDurability.PERSISTENT)); + + // fundWallet was reached exactly once, after the visibility poll resolved. + expect(mockTxOps.fundWalletCalls, hasLength(1)); + expect(mockTxOps.fundWalletCalls.single.nativeTokenContract, + equals(_contractA)); + }); + }); } From e912b2a76ecd2eaa1df635540abc2df50dc08e78 Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Sat, 27 Jun 2026 02:01:40 +0200 Subject: [PATCH 3/4] Add headless connectToContract to the OZ smart-account kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects the kit to a deployed smart account by contract address alone — no passkey credential, no WebAuthn ceremony, no session — for autonomous signers and backends operating through the multi-signer / external-signer pipeline. The single-passkey submit() path guards on a sentinel credential and fails fast if misrouted; a dedicated OZSmartAccountEventHeadlessConnected event is emitted. --- lib/src/smartaccount/oz/oz_constants.dart | 8 + .../oz/oz_smart_account_events.dart | 27 ++ .../oz/oz_transaction_operations.dart | 12 + .../smartaccount/oz/oz_wallet_operations.dart | 104 +++++ .../oz/oz_connect_to_contract_test.dart | 370 ++++++++++++++++++ 5 files changed, 521 insertions(+) create mode 100644 test/unit/smartaccount/oz/oz_connect_to_contract_test.dart diff --git a/lib/src/smartaccount/oz/oz_constants.dart b/lib/src/smartaccount/oz/oz_constants.dart index 1ea9572a..2357e2c6 100644 --- a/lib/src/smartaccount/oz/oz_constants.dart +++ b/lib/src/smartaccount/oz/oz_constants.dart @@ -48,6 +48,14 @@ class OZConstants { /// Maximum policies per context rule (OpenZeppelin contract limit). static const int maxPolicies = 5; + /// Sentinel credential ID stored in the connected state by + /// [OZWalletOperations.connectToContract]. The headless operate path + /// (multi-signer / external-signer) never reads `connected.credentialId`; + /// this value only preserves the non-null `OZConnectedState.credentialId` + /// invariant. The single-passkey `submit()` path guards on this exact value + /// and throws a clear error rather than attempting a ceremony with it. + static const String headlessCredentialSentinel = ''; + /// HTTP header identifying the SDK name sent with indexer and relayer /// requests. static const String clientNameHeader = 'X-Client-Name'; diff --git a/lib/src/smartaccount/oz/oz_smart_account_events.dart b/lib/src/smartaccount/oz/oz_smart_account_events.dart index 8a9e8ae1..1d726d29 100644 --- a/lib/src/smartaccount/oz/oz_smart_account_events.dart +++ b/lib/src/smartaccount/oz/oz_smart_account_events.dart @@ -66,6 +66,30 @@ final class OZSmartAccountEventWalletConnected extends OZSmartAccountEvent { int get hashCode => Object.hash(contractId, credentialId); } +/// Emitted when the kit connects headlessly to a smart account by contract +/// address alone, via [OZWalletOperations.connectToContract]. +/// +/// Unlike [OZSmartAccountEventWalletConnected] there is no passkey credential, +/// so the event carries only the contract address. +final class OZSmartAccountEventHeadlessConnected extends OZSmartAccountEvent { + const OZSmartAccountEventHeadlessConnected({required this.contractId}); + + final String contractId; + + @override + String get eventTypeName => 'HeadlessConnected'; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is OZSmartAccountEventHeadlessConnected && + other.contractId == contractId; + } + + @override + int get hashCode => contractId.hashCode; +} + /// Emitted when a wallet is disconnected. /// /// This event is fired when `disconnect()` is called. The session is cleared, @@ -506,6 +530,9 @@ class OZSmartAccountEventEmitter { if (E == OZSmartAccountEventWalletConnected) { return 'WalletConnected'; } + if (E == OZSmartAccountEventHeadlessConnected) { + return 'HeadlessConnected'; + } if (E == OZSmartAccountEventWalletDisconnected) { return 'WalletDisconnected'; } diff --git a/lib/src/smartaccount/oz/oz_transaction_operations.dart b/lib/src/smartaccount/oz/oz_transaction_operations.dart index d2550f30..24d025b3 100644 --- a/lib/src/smartaccount/oz/oz_transaction_operations.dart +++ b/lib/src/smartaccount/oz/oz_transaction_operations.dart @@ -406,6 +406,18 @@ class OZTransactionOperations { final credentialId = connected.credentialId; final contractId = connected.contractId; + // Reject the headless sentinel before any decode, ceremony, or network + // call. A kit connected via connectToContract has no passkey credential, + // so the single-passkey submit path cannot sign; headless operations must + // use the multi-signer / external-signer pipeline. + if (credentialId == OZConstants.headlessCredentialSentinel) { + throw SmartAccountValidationException.invalidInput( + 'credentialId', + 'This kit is connected headlessly (no passkey); use the multi-signer ' + 'pipeline with explicit selectedSigners for headless operations.', + ); + } + final deployer = await _kit.getDeployer(); final deployerAccount = await _fetchAccount(deployer.accountId); diff --git a/lib/src/smartaccount/oz/oz_wallet_operations.dart b/lib/src/smartaccount/oz/oz_wallet_operations.dart index 717a3719..17d1b51f 100644 --- a/lib/src/smartaccount/oz/oz_wallet_operations.dart +++ b/lib/src/smartaccount/oz/oz_wallet_operations.dart @@ -32,6 +32,7 @@ import 'oz_smart_account_types.dart'; import 'oz_storage_adapter.dart'; import 'oz_submission_routing.dart'; import 'oz_transaction_timeout.dart'; +import 'oz_validation.dart'; // Public result types @@ -232,6 +233,30 @@ final class OZConnectWalletAmbiguous extends OZConnectWalletResult { int get hashCode => Object.hash(credentialId, Object.hashAll(candidates)); } +/// Result of a successful headless [OZWalletOperations.connectToContract]. +/// +/// Carries only the connected contract address. Unlike +/// [OZConnectWalletConnected] there is no credential field — a headless +/// connection holds no passkey credential, only an internal sentinel — so +/// nothing here can leak the sentinel to callers. +class OZConnectToContractResult { + /// Constructs a headless connect result for [contractId]. + const OZConnectToContractResult({required this.contractId}); + + /// Smart-account contract address now connected (C-address). + final String contractId; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! OZConnectToContractResult) return false; + return contractId == other.contractId; + } + + @override + int get hashCode => contractId.hashCode; +} + /// Result of standalone passkey authentication. /// /// Contains the credential ID, normalised signature, and public key from a @@ -711,6 +736,85 @@ class OZWalletOperations { ); } + /// Connects to an existing smart account by contract address alone, with no + /// passkey credential. + /// + /// Intended for autonomous signers (a reference agent) and backend services + /// that operate a smart account through the multi-signer / external-signer + /// path. Unlike [connectWallet], this method performs no WebAuthn ceremony, + /// consults no indexer, and persists no session — it sets the in-memory + /// connected state and returns. + /// + /// The resulting connection supports ONLY the multi-signer / external-signer + /// pipeline (calls made with an explicit, non-empty `selectedSigners` list). + /// The single-passkey operations — [OZTransactionOperations.submit], + /// [OZTransactionOperations.executeAndSubmit], + /// [OZTransactionOperations.transfer], [OZTransactionOperations.contractCall], + /// and any manager call left at the default empty `selectedSigners` — throw + /// [SmartAccountValidationException] because there is no passkey credential + /// to sign with. + /// + /// Behaviour: + /// 1. Validates [contractId] is a Stellar contract address (C-address). + /// 2. Verifies the contract exists on-chain via the same instance-ledger + /// check used by [connectWallet]. + /// 3. Clears any persisted session (best-effort) so a later silent + /// reconnect does not resurrect a stale passkey session. Clearing is + /// best-effort: a persistent-storage write failure is swallowed rather + /// than failing the connect, so on such a failure a stale session may + /// survive and a later [connectWallet] could still restore it. + /// 4. Sets the connected state to [contractId] with an internal sentinel + /// credential ID (preserving the non-null `OZConnectedState` invariant). + /// 5. Emits [OZSmartAccountEventHeadlessConnected] (carrying only the + /// contract address — no credential). + /// + /// Does NOT save a session and does NOT touch the credential manager. + /// + /// The optional [cancelToken] can be cancelled to abort before the on-chain + /// verification; cancellation surfaces as a [SmartAccountTransactionException]. + /// + /// Throws [SmartAccountInvalidAddress] when [contractId] is not a valid + /// C-address, and [SmartAccountWalletException.notFound] when no contract + /// exists at [contractId]. + Future connectToContract( + String contractId, { + dio.CancelToken? cancelToken, + }) async { + _checkCancellation(cancelToken); + + // 1. Validate the address shape (CRC16-checked C-address). + requireContractAddress(contractId, fieldName: 'contractId'); + + // 2. Verify the contract exists on-chain (reuse the existing check). + await _verifyContractExists(contractId); + + // 3. Clear any persisted session so a later silent connectWallet() restore + // cannot resurrect a stale passkey session that contradicts this + // headless in-memory state. Best-effort; a storage failure must not + // leave the kit unconnected. + try { + await _kit.getStorage().clearSession(); + } catch (_) { + // Non-critical — clearing is best-effort. + } + + // 4. Set connected state with the real contractId and a sentinel + // credential, preserving the non-null OZConnectedState invariant. + await _kit.setConnectedState( + credentialId: OZConstants.headlessCredentialSentinel, + contractId: contractId, + ); + + // 5. Emit the dedicated headless event. No WalletConnected (which would + // leak the sentinel onto its non-null credentialId field), no session + // save, no credential manager. + _kit.events.emit( + OZSmartAccountEventHeadlessConnected(contractId: contractId), + ); + + return OZConnectToContractResult(contractId: contractId); + } + /// Performs the post-cascade verify, credential cleanup, state set, /// event emit, and session save. Used by both the WebAuthn cascade and /// (indirectly) by `_connectWithCredentials`. diff --git a/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart b/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart new file mode 100644 index 00000000..ab996a7c --- /dev/null +++ b/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart @@ -0,0 +1,370 @@ +// Copyright 2026 The Stellar Flutter SDK Authors. All rights reserved. +// Use of this source code is governed by a license that can be +// found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; + +import 'mock_oz_multi_signer_manager.dart'; +import 'oz_pipeline_fixtures.dart'; + +/// Valid smart-account contract address reused across the headless-connect +/// tests. +const String _contractA = + 'CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK'; + +/// Valid recipient G-address for the transfer guard sub-case. Distinct from +/// [_contractA] so the self-transfer check does not fire before the guard. +const String _recipientG = + 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; + +/// Builds a non-null instance ledger entry signalling "contract exists". +LedgerEntry _existingInstanceEntry() => LedgerEntry('', '', 0, null, null); + +/// Matcher for the single-passkey guard: a validation exception whose message +/// names the headless connection and directs the caller to the multi-signer +/// pipeline. Asserting the message distinguishes the guard from +/// address-validation, which also raises a [SmartAccountValidationException]. +final Matcher _throwsHeadlessGuard = throwsA( + isA() + .having((e) => e.message, 'message', contains('headlessly')) + .having((e) => e.message, 'message', contains('multi-signer pipeline')), +); + +void main() { + // ------------------------------------------------------------------------- + // OZConnectToContractResult value type + // ------------------------------------------------------------------------- + group('OZConnectToContractResult value type', () { + test('testConnectToContractResult_valueEqualityAndHashCode', () { + const a = OZConnectToContractResult(contractId: _contractA); + const b = OZConnectToContractResult(contractId: _contractA); + const c = OZConnectToContractResult( + contractId: + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + ); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a, isNot(equals(c))); + expect(a.contractId, equals(_contractA)); + }); + }); + + // ------------------------------------------------------------------------- + // connectToContract: happy path + // ------------------------------------------------------------------------- + group('connectToContract success', () { + test('testConnectToContract_existingContract_setsConnectedState', + () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + final result = await ops.connectToContract(_contractA); + + expect(kit.contractId, equals(_contractA)); + final state = await kit.requireConnected(); + expect(state.contractId, equals(_contractA)); + expect( + state.credentialId, + equals(OZConstants.headlessCredentialSentinel), + ); + expect(result.contractId, equals(_contractA)); + }); + + test('testConnectToContract_verifiesContractOnChain', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + await ops.connectToContract(_contractA); + + // The single queued instance entry was consumed by the on-chain verify. + expect(soroban.getContractDataResponses, isEmpty); + expect(soroban.getContractDataCalls.length, equals(1)); + expect(soroban.getContractDataCalls.single.contractId, equals(_contractA)); + }); + + test('testConnectToContract_emitsHeadlessConnectedEvent_only', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + final headlessEvents = []; + final walletEvents = []; + kit.events.on(headlessEvents.add); + kit.events.on(walletEvents.add); + + await ops.connectToContract(_contractA); + + expect(headlessEvents.length, equals(1)); + expect(headlessEvents.single.contractId, equals(_contractA)); + // No credential-bearing event — the sentinel must never leak. + expect(walletEvents, isEmpty); + }); + + test('testConnectToContract_doesNotSaveSession', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + expect(await kit.getStorage().getSession(), isNull); + await ops.connectToContract(_contractA); + expect(await kit.getStorage().getSession(), isNull); + }); + + test('testConnectToContract_clearsPreexistingSession', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + final now = DateTime.now().millisecondsSinceEpoch; + await kit.getStorage().saveSession( + OZStoredSession( + credentialId: 'real-passkey-credential', + contractId: + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + connectedAt: now, + expiresAt: now + 60_000, + ), + ); + expect(await kit.getStorage().getSession(), isNotNull); + + await ops.connectToContract(_contractA); + + // The stale passkey session is cleared so a later silent + // connectWallet() cannot resurrect it over the headless connection. + expect(await kit.getStorage().getSession(), isNull); + }); + + test('testConnectToContract_doesNotTouchCredentialManager', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final credentialManager = StubCredentialManager(); + final kit = FakePipelineKit( + sorobanServer: soroban, + credentialManager: credentialManager, + ); + final ops = OZWalletOperations(kit); + + final deletedEvents = []; + kit.events.on(deletedEvents.add); + + await ops.connectToContract(_contractA); + + // No credential is created, deleted, or stored for the sentinel. + expect(credentialManager.deletedCredentialIds, isEmpty); + expect( + credentialManager.peek(OZConstants.headlessCredentialSentinel), + isNull, + ); + expect(deletedEvents, isEmpty); + }); + + test('testConnectToContract_isConnectedReflectsState', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + // Before: not connected. + expect(kit.contractId, isNull); + await expectLater( + () => kit.requireConnected(), + throwsA(isA()), + ); + + await ops.connectToContract(_contractA); + + // After: connected with the sentinel credential. + expect(kit.contractId, equals(_contractA)); + final state = await kit.requireConnected(); + expect(state.contractId, equals(_contractA)); + expect( + state.credentialId, + equals(OZConstants.headlessCredentialSentinel), + ); + }); + }); + + // ------------------------------------------------------------------------- + // connectToContract: error paths + // ------------------------------------------------------------------------- + group('connectToContract errors', () { + test('testConnectToContract_nonexistentContract_throwsNotFound', () async { + final soroban = MockSorobanServer(); + // A null instance entry signals "no contract on-chain". + soroban.getContractDataResponses.add(null); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + await expectLater( + () => ops.connectToContract(_contractA), + throwsA(isA()), + ); + + // Connection state is not set on failure. + expect(kit.contractId, isNull); + await expectLater( + () => kit.requireConnected(), + throwsA(isA()), + ); + }); + + test('testConnectToContract_invalidContractId_throwsInvalidAddress', + () async { + final soroban = MockSorobanServer(); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + await expectLater( + () => ops.connectToContract('not-a-c-address'), + throwsA(isA()), + ); + + // Validation precedes any network call and leaves the state unset. + expect(soroban.getContractDataCalls, isEmpty); + expect(kit.contractId, isNull); + await expectLater( + () => kit.requireConnected(), + throwsA(isA()), + ); + }); + }); + + // ------------------------------------------------------------------------- + // Single-passkey guard after a headless connect + // ------------------------------------------------------------------------- + group('connectToContract single-passkey guard', () { + test('testConnectToContract_thenSinglePasskeySubmit_throwsGuard', + () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + await ops.connectToContract(_contractA); + // The connect verify consumed exactly one getContractData lookup. + expect(soroban.getContractDataCalls.length, equals(1)); + + final txOps = kit.transactionOperations; + + // Direct submit(). + final hostFunction = XdrHostFunction.forInvokingContractWithArgs( + XdrInvokeContractArgs( + Address.forContractId(_contractA).toXdr(), + 'noop', + const [], + ), + ); + await expectLater( + () => txOps.submit( + hostFunction: hostFunction, + auth: const [], + ), + _throwsHeadlessGuard, + ); + + // executeAndSubmit() with a valid contract target. + await expectLater( + () => txOps.executeAndSubmit(target: _contractA, targetFn: 'noop'), + _throwsHeadlessGuard, + ); + + // contractCall() with a valid contract target. + await expectLater( + () => txOps.contractCall(target: _contractA, targetFn: 'noop'), + _throwsHeadlessGuard, + ); + + // transfer() with explicit decimals (no on-chain decimals lookup) and a + // valid recipient distinct from the connected contract. + await expectLater( + () => txOps.transfer( + tokenContract: _contractA, + recipient: _recipientG, + amount: '1', + decimals: 7, + ), + _throwsHeadlessGuard, + ); + + // A manager call left at the default empty selectedSigners routes into + // submit() via ozRouteSubmission and hits the same guard. + final signerManager = OZSignerManager(kit); + await expectLater( + () => signerManager.removeSigner(contextRuleId: 0, signerId: 0), + _throwsHeadlessGuard, + ); + + // The guard fires before any network step in every sub-case: no + // additional getContractData lookup, no simulation, no send, no account + // fetch beyond the single connect verify. + expect(soroban.getContractDataCalls.length, equals(1)); + expect(soroban.simulateCalls, isEmpty); + expect(soroban.sendCalls, isEmpty); + expect(soroban.getAccountCalls, isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // Multi-signer path composes with a headless connect + // ------------------------------------------------------------------------- + group('connectToContract multi-signer path', () { + test('testConnectToContract_thenMultiSignerRouting_reachesMultiSignerManager', + () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final multi = MockOZMultiSignerManager(kit); + kit.setMultiSignerManager(multi); + + await OZWalletOperations(kit).connectToContract(_contractA); + // The connect verify consumed exactly one getContractData lookup. + expect(soroban.getContractDataCalls.length, equals(1)); + + final ed25519Signer = OZSelectedSignerEd25519( + verifierAddress: _contractA, + publicKey: Uint8List.fromList( + List.generate(32, (i) => i & 0xFF), + ), + ); + + // A real manager call carrying a NON-empty selectedSigners list routes + // through the production ozRouteSubmission. Its non-empty branch must + // reach submitWithMultipleSigners and never enter the guarded submit() + // path. removeSigner mirrors the default-empty guard sub-case in test 9, + // which routes the empty list into submit() and throws the headless + // guard; here the non-empty list takes the multi-signer branch instead. + final result = await OZSignerManager(kit).removeSigner( + contextRuleId: 0, + signerId: 0, + selectedSigners: [ed25519Signer], + ); + + expect(result.success, isTrue); + + // Routing reached the multi-signer manager with the supplied signer and + // did not divert to multiSignerContractCall or the other entry points. + final call = multi.submitWithMultipleSignersCalls.single; + expect(call.selectedSigners.single, isA()); + expect(multi.multiSignerContractCallCalls, isEmpty); + + // The single-passkey submit() path was not taken: no headless guard + // threw (the call above completed), and no submit-side network ran + // beyond the connect verify. + expect(soroban.getContractDataCalls.length, equals(1)); + expect(soroban.simulateCalls, isEmpty); + expect(soroban.sendCalls, isEmpty); + expect(soroban.getAccountCalls, isEmpty); + }); + }); +} From f0ced95602dbc5ff9541379188ad169229e78b6e Mon Sep 17 00:00:00 2001 From: Christian Rogobete Date: Sun, 28 Jun 2026 16:40:27 +0200 Subject: [PATCH 4/4] Model headless connections with a null credential and isHeadless - Represent a headless connection with a null credentialId and a new isHeadless discriminator; OZConnectedState.credentialId is now nullable and isConnected is gated on the bound contract address. - Narrow the RPC visibility-poll catches to Exception so programmer errors propagate, and correct the timeout-cause documentation. - Cover headless connect with tests (cancellation, best-effort session clear, event equality, and the real kit's connection-state API), and make the test fixture's disconnect emit for any contract-bound state. - Add a CHANGELOG entry for the new API and the nullability change. --- CHANGELOG.md | 5 + lib/src/smartaccount/oz/oz_constants.dart | 8 -- .../oz/oz_internal_pipeline_interfaces.dart | 9 ++ .../smartaccount/oz/oz_smart_account_kit.dart | 68 ++++++--- .../oz/oz_smart_account_types.dart | 16 ++- .../oz/oz_transaction_operations.dart | 43 +++--- .../smartaccount/oz/oz_wallet_operations.dart | 70 +++++---- .../oz/oz_connect_to_contract_test.dart | 135 +++++++++++++++--- .../smartaccount/oz/oz_constants_test.dart | 11 +- .../oz/oz_contract_visibility_poll_test.dart | 4 +- .../oz_fund_wallet_visibility_poll_test.dart | 5 +- .../smartaccount/oz/oz_pipeline_fixtures.dart | 34 ++++- .../oz/oz_smart_account_events_test.dart | 21 +++ .../oz/oz_smart_account_kit_test.dart | 55 ++++++- .../oz/oz_wallet_create_pipeline_test.dart | 4 +- 15 files changed, 367 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a0cb8bb..51248a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [Unreleased] +- Add headless `connectToContract` to connect an OpenZeppelin smart account by its contract address alone, with no passkey credential, for autonomous signing processes and backend services that operate through the multi-signer / external-signer path. Adds `OZConnectToContractResult`, the `OZSmartAccountEventHeadlessConnected` event, and the `OZSmartAccountKit.isHeadless` / `OZConnectedState.isHeadless` getters. +- `OZConnectedState.credentialId` is now nullable (`String?`); a `null` value indicates a headless connection. This is a minor breaking change for code that reads `credentialId` as a non-null `String`. +- Poll the Soroban RPC for funding-account and contract-instance visibility instead of waiting a fixed delay, improving the reliability of wallet creation and contract connection. + ## [3.2.0] - 19.Jun.2026. - Add Protocol 27 (CAP-71) Soroban authorization support: the ADDRESS_V2 and ADDRESS_WITH_DELEGATES credential arms with delegated account authorization - Update compatibility matrices to Horizon v27.0.0 and Soroban RPC v27.0.0 (coverage unchanged at 100%) diff --git a/lib/src/smartaccount/oz/oz_constants.dart b/lib/src/smartaccount/oz/oz_constants.dart index 2357e2c6..1ea9572a 100644 --- a/lib/src/smartaccount/oz/oz_constants.dart +++ b/lib/src/smartaccount/oz/oz_constants.dart @@ -48,14 +48,6 @@ class OZConstants { /// Maximum policies per context rule (OpenZeppelin contract limit). static const int maxPolicies = 5; - /// Sentinel credential ID stored in the connected state by - /// [OZWalletOperations.connectToContract]. The headless operate path - /// (multi-signer / external-signer) never reads `connected.credentialId`; - /// this value only preserves the non-null `OZConnectedState.credentialId` - /// invariant. The single-passkey `submit()` path guards on this exact value - /// and throws a clear error rather than attempting a ceremony with it. - static const String headlessCredentialSentinel = ''; - /// HTTP header identifying the SDK name sent with indexer and relayer /// requests. static const String clientNameHeader = 'X-Client-Name'; diff --git a/lib/src/smartaccount/oz/oz_internal_pipeline_interfaces.dart b/lib/src/smartaccount/oz/oz_internal_pipeline_interfaces.dart index 080607e4..bfdfbe14 100644 --- a/lib/src/smartaccount/oz/oz_internal_pipeline_interfaces.dart +++ b/lib/src/smartaccount/oz/oz_internal_pipeline_interfaces.dart @@ -153,6 +153,15 @@ abstract class OZSmartAccountKitInterface { required String contractId, }); + /// Sets the kit's connected state to a headless connection bound to + /// [contractId] with no passkey credential. + /// + /// Implementations route the field writes through their state-mutation + /// lock so concurrent [requireConnected] / `disconnect` / `close` calls + /// observe a coherent state in which a contract is bound but no credential + /// is present. + Future setHeadlessConnectedState({required String contractId}); + /// The currently connected smart-account contract address, or `null` /// when no wallet is connected. Distinct from [requireConnected]: the /// throwing path is for state-changing flows that cannot proceed diff --git a/lib/src/smartaccount/oz/oz_smart_account_kit.dart b/lib/src/smartaccount/oz/oz_smart_account_kit.dart index 3ec63f01..f32da84b 100644 --- a/lib/src/smartaccount/oz/oz_smart_account_kit.dart +++ b/lib/src/smartaccount/oz/oz_smart_account_kit.dart @@ -217,16 +217,31 @@ class OZSmartAccountKit implements OZSmartAccountWalletKitInterface { /// Whether a wallet is currently connected. /// - /// Returns `true` when both the credential ID and contract ID are - /// non-null. Reflects in-memory state only; after an app restart a - /// previously-saved session must be restored via - /// [OZWalletOperations.connectWallet] before [isConnected] reads as - /// `true` again. - bool get isConnected => _credentialId != null && _contractId != null; - - /// Connected credential ID (Base64URL-encoded, no padding), or `null` when no wallet is connected. + /// Returns `true` when a contract address is bound, covering both a + /// passkey connection and a headless connection established via + /// [OZWalletOperations.connectToContract]. Reflects in-memory state only; + /// after an app restart a previously-saved session must be restored via + /// [OZWalletOperations.connectWallet] before [isConnected] reads as `true` + /// again. + bool get isConnected => _contractId != null; + + /// Connected credential ID (Base64URL-encoded, no padding), or `null` when + /// no wallet is connected or the wallet is connected headlessly via + /// [OZWalletOperations.connectToContract] (which holds no passkey + /// credential). Use [isHeadless] to tell a headless connection apart from + /// no connection. String? get credentialId => _credentialId; + /// Whether the current connection is headless — a connection established + /// via [OZWalletOperations.connectToContract] that binds a contract address + /// but holds no passkey credential. + /// + /// `true` exactly when a contract address is bound while [credentialId] is + /// `null`. A headless connection supports only the multi-signer / + /// external-signer pipeline; the single-passkey operations + /// (e.g. [OZTransactionOperations.submit]) reject it. + bool get isHeadless => _contractId != null && _credentialId == null; + /// Connected smart-account contract address (C-address), or `null` /// when no wallet is connected. @override @@ -234,13 +249,14 @@ class OZSmartAccountKit implements OZSmartAccountWalletKitInterface { // Connection management - /// Updates the connected state to the supplied [credentialId] / - /// [contractId] pair. + /// Updates the connected state to bind the passkey [credentialId] to the + /// [contractId]. /// /// Called by [walletOperations] after wallet creation and after a - /// successful connect or reconnect. The write is routed through - /// [_withLock]; both scalar field writes happen inside the same lock - /// acquisition so observers always see a coherent + /// successful passkey connect or reconnect. For a headless connection that + /// carries no credential use [setHeadlessConnectedState]. The write is + /// routed through [_withLock]; both scalar field writes happen inside the + /// same lock acquisition so observers always see a coherent /// (`credentialId`, `contractId`) pair. @override Future setConnectedState({ @@ -253,14 +269,34 @@ class OZSmartAccountKit implements OZSmartAccountWalletKitInterface { }); } - /// Returns the connected credential ID and contract address, or throws - /// [SmartAccountWalletNotConnected] when no wallet is connected. + /// Updates the connected state to a headless connection bound to + /// [contractId] with no passkey credential. + /// + /// Called by [walletOperations] after a successful + /// [OZWalletOperations.connectToContract]. The write is routed through + /// [_withLock]; clearing the credential and setting the contract happen + /// inside the same lock acquisition so observers always see a coherent + /// state in which a contract is bound and [credentialId] is `null` + /// (i.e. [isHeadless] is `true`). + @override + Future setHeadlessConnectedState({required String contractId}) async { + await _withLock(() { + _credentialId = null; + _contractId = contractId; + }); + } + + /// Returns the connected contract address and, for a passkey connection, + /// the credential ID, or throws [SmartAccountWalletNotConnected] when no + /// wallet is connected. For a headless connection the returned + /// [OZConnectedState.credentialId] is `null` and + /// [OZConnectedState.isHeadless] is `true`. @override Future requireConnected() async { return _withLock(() { final cId = _credentialId; final ctId = _contractId; - if (cId == null || ctId == null) { + if (ctId == null) { throw SmartAccountWalletException.notConnected( details: 'No wallet connected. Call createWallet() or connectWallet() first.', diff --git a/lib/src/smartaccount/oz/oz_smart_account_types.dart b/lib/src/smartaccount/oz/oz_smart_account_types.dart index d33e0f45..ccd76ae7 100644 --- a/lib/src/smartaccount/oz/oz_smart_account_types.dart +++ b/lib/src/smartaccount/oz/oz_smart_account_types.dart @@ -339,8 +339,11 @@ enum OZSubmissionMethod { rpc, } -/// Connected-wallet state. Carries the Base64URL credential ID and the -/// smart-account contract address resolved during connection. +/// Connected-wallet state. Carries the smart-account contract address and, +/// for a passkey connection, the Base64URL credential ID resolved during +/// connection. A headless connection established via +/// [OZWalletOperations.connectToContract] holds no passkey credential: +/// [credentialId] is `null` and [isHeadless] is `true`. class OZConnectedState { /// Constructs a connected state record. const OZConnectedState({ @@ -348,12 +351,17 @@ class OZConnectedState { required this.contractId, }); - /// Base64URL-encoded WebAuthn credential ID. - final String credentialId; + /// Base64URL-encoded WebAuthn credential ID, or `null` for a headless + /// connection that holds no passkey credential. + final String? credentialId; /// Smart-account contract address (C-address). final String contractId; + /// Whether this is a headless connection that holds no passkey credential. + /// `true` exactly when [credentialId] is `null`. + bool get isHeadless => credentialId == null; + @override bool operator ==(Object other) { if (identical(this, other)) return true; diff --git a/lib/src/smartaccount/oz/oz_transaction_operations.dart b/lib/src/smartaccount/oz/oz_transaction_operations.dart index 24d025b3..26e2861c 100644 --- a/lib/src/smartaccount/oz/oz_transaction_operations.dart +++ b/lib/src/smartaccount/oz/oz_transaction_operations.dart @@ -406,11 +406,12 @@ class OZTransactionOperations { final credentialId = connected.credentialId; final contractId = connected.contractId; - // Reject the headless sentinel before any decode, ceremony, or network - // call. A kit connected via connectToContract has no passkey credential, - // so the single-passkey submit path cannot sign; headless operations must - // use the multi-signer / external-signer pipeline. - if (credentialId == OZConstants.headlessCredentialSentinel) { + // Reject a headless connection before any decode, ceremony, or network + // call. A kit connected via connectToContract has no passkey credential + // (credentialId is null), so the single-passkey submit path cannot sign; + // headless operations must use the multi-signer / external-signer + // pipeline. + if (credentialId == null) { throw SmartAccountValidationException.invalidInput( 'credentialId', 'This kit is connected headlessly (no passkey); use the multi-signer ' @@ -842,12 +843,8 @@ class OZTransactionOperations { throw SmartAccountTransactionException.submissionFailed('Friendbot funding failed'); } - // why: Friendbot returns once Horizon has confirmed the deposit but the - // Soroban RPC simulation endpoint can lag behind by one or more ledger - // closes under testnet congestion. Simulating the native SAC balance read - // before the RPC has applied the funding ledger fails with an opaque - // "account entry is missing" contract error, so poll the RPC until it - // sees the account rather than waiting a fixed, optimistic interval. + // Wait for RPC visibility of the funded account before the native SAC + // balance read simulates against it (see waitForAccountVisibleToRpc). await waitForAccountVisibleToRpc(tempKeypair.accountId, cancelToken); final tempAccount = await _fetchAccount(tempKeypair.accountId); @@ -1381,9 +1378,14 @@ class OZTransactionOperations { /// poll runs every [pollInterval] up to [timeout]; both default to the /// [OZConstants.rpcVisibilityPollIntervalMs] / /// [OZConstants.rpcVisibilityTimeoutSeconds] budgets and are - /// overridable so the behaviour can be exercised in tests. Transient RPC - /// errors are retried until the deadline and the most recent one is - /// attached to the timeout error as its cause. + /// overridable so the behaviour can be exercised in tests. + /// + /// A thrown RPC error (transport failure, 5xx) is treated as transient and + /// retried until the deadline, and the most recent one is attached to the + /// timeout error as its cause. An in-band JSON-RPC error envelope (HTTP 200 + /// with an `error` member) is instead returned by [SorobanServer.getAccount] + /// as a `null` result, indistinguishable from a not-yet-visible account, so + /// it is retried the same way and the timeout error then carries no cause. /// /// [cancelToken] is observed before each poll and during each inter-poll /// sleep so the caller can abort in flight. Throws @@ -1407,11 +1409,14 @@ class OZTransactionOperations { if (account != null) { return; } - } catch (e) { - // Any error raised during the lookup is treated as transient and - // retried until the deadline; the most recent one is surfaced on - // timeout. The production caller always passes a valid accountId, so - // in practice these are RPC-level failures (congestion, 5xx). + } on Exception catch (e) { + // Transient RPC exceptions are treated as "not yet visible" and + // retried until the deadline; the most recent one is surfaced as the + // timeout cause. The production caller always passes a valid accountId, + // so in practice these are RPC-level failures (congestion, 5xx). Error + // subclasses (programmer/RPC-client bugs) are not caught here, so they + // propagate immediately instead of being masked for the full timeout + // budget. lastError = e; } if (!DateTime.now().isBefore(deadline)) { diff --git a/lib/src/smartaccount/oz/oz_wallet_operations.dart b/lib/src/smartaccount/oz/oz_wallet_operations.dart index 17d1b51f..11c99a00 100644 --- a/lib/src/smartaccount/oz/oz_wallet_operations.dart +++ b/lib/src/smartaccount/oz/oz_wallet_operations.dart @@ -236,9 +236,8 @@ final class OZConnectWalletAmbiguous extends OZConnectWalletResult { /// Result of a successful headless [OZWalletOperations.connectToContract]. /// /// Carries only the connected contract address. Unlike -/// [OZConnectWalletConnected] there is no credential field — a headless -/// connection holds no passkey credential, only an internal sentinel — so -/// nothing here can leak the sentinel to callers. +/// [OZConnectWalletConnected] there is no credential field: a headless +/// connection holds no passkey credential. class OZConnectToContractResult { /// Constructs a headless connect result for [contractId]. const OZConnectToContractResult({required this.contractId}); @@ -555,12 +554,8 @@ class OZWalletOperations { 'nativeTokenContract is required when autoFund is true', ); } - // why: the deploy transaction confirms once the network applies it, - // but the Soroban RPC simulation endpoint can lag behind by one or - // more ledger closes under testnet congestion. fundWallet simulates - // against the smart-account contract, so wait until the RPC sees the - // deployed contract instance rather than sleeping a fixed, optimistic - // interval that fails when propagation runs long. + // Wait for RPC visibility of the deployed instance before fundWallet + // simulates against it (see waitForContractVisibleToRpc). await waitForContractVisibleToRpc(contractId, cancelToken); await _kit.transactionOperations.fundWallet( nativeTokenContract: tokenContract, @@ -739,7 +734,7 @@ class OZWalletOperations { /// Connects to an existing smart account by contract address alone, with no /// passkey credential. /// - /// Intended for autonomous signers (a reference agent) and backend services + /// Intended for autonomous signing processes and backend services /// that operate a smart account through the multi-signer / external-signer /// path. Unlike [connectWallet], this method performs no WebAuthn ceremony, /// consults no indexer, and persists no session — it sets the in-memory @@ -763,8 +758,10 @@ class OZWalletOperations { /// best-effort: a persistent-storage write failure is swallowed rather /// than failing the connect, so on such a failure a stale session may /// survive and a later [connectWallet] could still restore it. - /// 4. Sets the connected state to [contractId] with an internal sentinel - /// credential ID (preserving the non-null `OZConnectedState` invariant). + /// 4. Sets the headless connected state: [contractId] is bound but no + /// passkey credential is present, so [OZSmartAccountKit.credentialId] + /// and [OZConnectedState.credentialId] read as `null` and + /// [OZSmartAccountKit.isHeadless] reads as `true`. /// 5. Emits [OZSmartAccountEventHeadlessConnected] (carrying only the /// contract address — no credential). /// @@ -798,16 +795,13 @@ class OZWalletOperations { // Non-critical — clearing is best-effort. } - // 4. Set connected state with the real contractId and a sentinel - // credential, preserving the non-null OZConnectedState invariant. - await _kit.setConnectedState( - credentialId: OZConstants.headlessCredentialSentinel, - contractId: contractId, - ); + // 4. Set the headless connected state: the contract address is bound but + // no passkey credential is present, so credentialId reads as null. + await _kit.setHeadlessConnectedState(contractId: contractId); - // 5. Emit the dedicated headless event. No WalletConnected (which would - // leak the sentinel onto its non-null credentialId field), no session - // save, no credential manager. + // 5. Emit the dedicated headless event. No WalletConnected event (its + // credentialId field is for passkey connections), no session save, no + // credential manager write. _kit.events.emit( OZSmartAccountEventHeadlessConnected(contractId: contractId), ); @@ -1120,12 +1114,8 @@ class OZWalletOperations { ); if (autoFund) { - // why: the deploy transaction confirms once the network applies it, - // but the Soroban RPC simulation endpoint can lag behind by one or - // more ledger closes under testnet congestion. fundWallet simulates - // against the smart-account contract, so wait until the RPC sees the - // deployed contract instance rather than sleeping a fixed, optimistic - // interval that fails when propagation runs long. + // Wait for RPC visibility of the deployed instance before fundWallet + // simulates against it (see waitForContractVisibleToRpc). await waitForContractVisibleToRpc(contractId, cancelToken); await _kit.transactionOperations.fundWallet( nativeTokenContract: nativeTokenContract!, @@ -1771,9 +1761,15 @@ class OZWalletOperations { /// here. The poll runs every [pollInterval] up to [timeout]; both default to /// the [OZConstants.rpcVisibilityPollIntervalMs] / /// [OZConstants.rpcVisibilityTimeoutSeconds] budgets and are overridable so - /// the behaviour can be exercised in tests. Transient RPC errors are retried - /// until the deadline and the most recent one is attached to the timeout - /// error as its cause. + /// the behaviour can be exercised in tests. + /// + /// A thrown RPC error (transport failure, 5xx) is treated as transient and + /// retried until the deadline, and the most recent one is attached to the + /// timeout error as its cause. An in-band JSON-RPC error envelope (HTTP 200 + /// with an `error` member) is instead returned by + /// [SorobanServer.getContractData] as a `null` result, indistinguishable + /// from a not-yet-visible instance, so it is retried the same way and the + /// timeout error then carries no cause. /// /// [cancelToken] is observed before each poll and during each inter-poll /// sleep so the caller can abort in flight. Throws @@ -1801,12 +1797,14 @@ class OZWalletOperations { if (entry != null) { return; } - } catch (e) { - // Any error raised during the lookup is treated as transient and - // retried until the deadline; the most recent one is surfaced on - // timeout. The production caller always passes a freshly-deployed - // contractId, so in practice these are RPC-level failures - // (congestion, 5xx). + } on Exception catch (e) { + // Transient RPC exceptions are treated as "not yet visible" and + // retried until the deadline; the most recent one is surfaced as the + // timeout cause. The production caller always passes a freshly-deployed + // contractId, so in practice these are RPC-level failures (congestion, + // 5xx). Error subclasses (programmer/RPC-client bugs) are not caught + // here, so they propagate immediately instead of being masked for the + // full timeout budget. lastError = e; } if (!DateTime.now().isBefore(deadline)) { diff --git a/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart b/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart index ab996a7c..a84e5570 100644 --- a/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart +++ b/test/unit/smartaccount/oz/oz_connect_to_contract_test.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; +import 'package:dio/dio.dart' as dio; import 'package:flutter_test/flutter_test.dart'; import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; @@ -66,13 +67,14 @@ void main() { final result = await ops.connectToContract(_contractA); + expect(kit.isConnected, isTrue); expect(kit.contractId, equals(_contractA)); + expect(kit.credentialId, isNull); + expect(kit.isHeadless, isTrue); final state = await kit.requireConnected(); expect(state.contractId, equals(_contractA)); - expect( - state.credentialId, - equals(OZConstants.headlessCredentialSentinel), - ); + expect(state.credentialId, isNull); + expect(state.isHeadless, isTrue); expect(result.contractId, equals(_contractA)); }); @@ -105,7 +107,7 @@ void main() { expect(headlessEvents.length, equals(1)); expect(headlessEvents.single.contractId, equals(_contractA)); - // No credential-bearing event — the sentinel must never leak. + // No credential-bearing event — a headless connection carries no credential. expect(walletEvents, isEmpty); }); @@ -145,6 +147,51 @@ void main() { expect(await kit.getStorage().getSession(), isNull); }); + test('testConnectToContract_clearSessionFailure_stillConnects', () async { + final soroban = MockSorobanServer(); + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit( + sorobanServer: soroban, + storage: _ClearSessionFaultStorage(), + ); + final ops = OZWalletOperations(kit); + + // A stale passkey session is present before the headless connect. + final now = DateTime.now().millisecondsSinceEpoch; + await kit.getStorage().saveSession( + OZStoredSession( + credentialId: 'real-passkey-credential', + contractId: + 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM', + connectedAt: now, + expiresAt: now + 60_000, + ), + ); + + final headlessEvents = []; + kit.events.on(headlessEvents.add); + + // Clearing the persisted session is best-effort: a storage write + // failure is swallowed rather than failing the connect, so the call + // still resolves and the headless state is set. + final result = await ops.connectToContract(_contractA); + + expect(result.contractId, equals(_contractA)); + expect(kit.contractId, equals(_contractA)); + final state = await kit.requireConnected(); + expect(state.contractId, equals(_contractA)); + expect(state.credentialId, isNull); + expect(state.isHeadless, isTrue); + + // The dedicated headless event is emitted despite the clear failure. + expect(headlessEvents.length, equals(1)); + expect(headlessEvents.single.contractId, equals(_contractA)); + + // The clear failed and was swallowed, so the stale session survives — + // the documented consequence of best-effort clearing. + expect(await kit.getStorage().getSession(), isNotNull); + }); + test('testConnectToContract_doesNotTouchCredentialManager', () async { final soroban = MockSorobanServer(); soroban.getContractDataResponses.add(_existingInstanceEntry()); @@ -160,12 +207,10 @@ void main() { await ops.connectToContract(_contractA); - // No credential is created, deleted, or stored for the sentinel. + // connectToContract performs no WebAuthn ceremony and writes nothing to + // the credential store: no credential is created, deleted, or stored. + expect(credentialManager.storedCredentialIds, isEmpty); expect(credentialManager.deletedCredentialIds, isEmpty); - expect( - credentialManager.peek(OZConstants.headlessCredentialSentinel), - isNull, - ); expect(deletedEvents, isEmpty); }); @@ -184,14 +229,15 @@ void main() { await ops.connectToContract(_contractA); - // After: connected with the sentinel credential. + // After: connected headlessly (contract bound, no passkey credential). + expect(kit.isConnected, isTrue); expect(kit.contractId, equals(_contractA)); + expect(kit.credentialId, isNull); + expect(kit.isHeadless, isTrue); final state = await kit.requireConnected(); expect(state.contractId, equals(_contractA)); - expect( - state.credentialId, - equals(OZConstants.headlessCredentialSentinel), - ); + expect(state.credentialId, isNull); + expect(state.isHeadless, isTrue); }); }); @@ -240,6 +286,45 @@ void main() { }); }); + // ------------------------------------------------------------------------- + // connectToContract: cancellation + // ------------------------------------------------------------------------- + group('connectToContract cancellation', () { + test('testConnectToContract_cancelledBeforeAwait_throwsSubmissionFailed', + () async { + final soroban = MockSorobanServer(); + // Seed an existing-contract entry that must never be consumed: the + // entry-time cancellation check runs before the on-chain verify, so a + // pre-cancelled token aborts before getContractData is ever reached. + soroban.getContractDataResponses.add(_existingInstanceEntry()); + final kit = FakePipelineKit(sorobanServer: soroban); + final ops = OZWalletOperations(kit); + + final token = dio.CancelToken(); + token.cancel('test'); + + await expectLater( + () => ops.connectToContract(_contractA, cancelToken: token), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Operation cancelled'), + )), + ); + + // Cancellation preceded the on-chain verify and the state set: the + // queued instance entry is untouched, no getContractData lookup ran, + // and the connection state was never established. + expect(soroban.getContractDataResponses.length, equals(1)); + expect(soroban.getContractDataCalls, isEmpty); + expect(kit.contractId, isNull); + await expectLater( + () => kit.requireConnected(), + throwsA(isA()), + ); + }); + }); + // ------------------------------------------------------------------------- // Single-passkey guard after a headless connect // ------------------------------------------------------------------------- @@ -341,9 +426,10 @@ void main() { // A real manager call carrying a NON-empty selectedSigners list routes // through the production ozRouteSubmission. Its non-empty branch must // reach submitWithMultipleSigners and never enter the guarded submit() - // path. removeSigner mirrors the default-empty guard sub-case in test 9, - // which routes the empty list into submit() and throws the headless - // guard; here the non-empty list takes the multi-signer branch instead. + // path. removeSigner mirrors the default-empty guard sub-case covered + // by testConnectToContract_thenSinglePasskeySubmit_throwsGuard, which + // routes the empty list into submit() and throws the headless guard; + // here the non-empty list takes the multi-signer branch instead. final result = await OZSignerManager(kit).removeSigner( contextRuleId: 0, signerId: 0, @@ -368,3 +454,16 @@ void main() { }); }); } + +/// Storage double whose [clearSession] always fails with the documented +/// [SmartAccountStorageWriteFailed]. Drives the best-effort session-clear +/// branch of [OZWalletOperations.connectToContract]: the failure must be +/// swallowed so the connect still completes. Save/read operations delegate to +/// the in-memory base so a pre-seeded session can be observed to survive the +/// failed clear. +class _ClearSessionFaultStorage extends OZInMemoryStorageAdapter { + @override + Future clearSession() async { + throw SmartAccountStorageException.writeFailed('session'); + } +} diff --git a/test/unit/smartaccount/oz/oz_constants_test.dart b/test/unit/smartaccount/oz/oz_constants_test.dart index 87fdfbf9..a9ae3c33 100644 --- a/test/unit/smartaccount/oz/oz_constants_test.dart +++ b/test/unit/smartaccount/oz/oz_constants_test.dart @@ -56,10 +56,12 @@ void main() { expect(OZConstants.clientName, 'flutter-stellar-sdk'); }); - test('test_OZConstants_exposes_exactly_16_public_constants', () { - // Inventory check: the sixteen values below must all be present and - // surfacing the correct constants. If a new constant is added or one is - // removed/renamed, this list will fall out of date and force review. + test('test_OZConstants_publicConstants_areNonNull_inventoryGuard', () { + // Inventory guard against renames and removals: every constant below is + // referenced by name, so renaming or removing one breaks the reference + // and fails compilation. Additions are NOT detected automatically — a + // newly added constant stays absent from this list until an author adds + // it, so keeping the inventory complete relies on author discipline. final values = [ OZConstants.defaultSessionExpiryMs, OZConstants.defaultIndexerTimeoutMs, @@ -78,7 +80,6 @@ void main() { OZConstants.maxIndexerConnectTimeoutMs, OZConstants.maxRelayerConnectTimeoutMs, ]; - expect(values.length, 16); expect(values, everyElement(isNotNull)); }); }); diff --git a/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart b/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart index 55e97643..4889bb7a 100644 --- a/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart +++ b/test/unit/smartaccount/oz/oz_contract_visibility_poll_test.dart @@ -11,7 +11,7 @@ import 'oz_pipeline_fixtures.dart'; const String _contractA = 'CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK'; -/// Builds a populated [LedgerEntry] standing in for a visible contract +/// Builds a minimal non-null [LedgerEntry] standing in for a visible contract /// instance. Only its non-null presence matters to the poll; the encoded /// payload is never inspected. LedgerEntry _visibleInstanceEntry() => LedgerEntry('', '', 0, null, null); @@ -27,7 +27,7 @@ class _ContractDataCall { } /// Scriptable [SorobanServer] double whose `getContractData` faithfully -/// returns `null` (the real RPC's "not yet visible" signal), a populated +/// returns `null` (the real RPC's "not yet visible" signal), a non-null /// [LedgerEntry] (visible), or a thrown error (transient RPC failure). /// /// The shared [MockSorobanServer] cannot represent `getContractData` returning diff --git a/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart b/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart index 5eb189f4..5d4173c0 100644 --- a/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart +++ b/test/unit/smartaccount/oz/oz_fund_wallet_visibility_poll_test.dart @@ -122,7 +122,10 @@ void main() { (e) => e.message, 'message', contains('Retry shortly'), - ), + ) + // A pure timeout (no transient errors observed) carries no + // spurious "last error" cause. + .having((e) => e.cause, 'cause', isNull), ), ); diff --git a/test/unit/smartaccount/oz/oz_pipeline_fixtures.dart b/test/unit/smartaccount/oz/oz_pipeline_fixtures.dart index 3b698b13..57fff11a 100644 --- a/test/unit/smartaccount/oz/oz_pipeline_fixtures.dart +++ b/test/unit/smartaccount/oz/oz_pipeline_fixtures.dart @@ -146,11 +146,9 @@ class FakePipelineKit implements OZSmartAccountWalletKitInterface { } /// Test-only `disconnect()`. Clears the connected state, emits the - /// `WalletDisconnected` event when a wallet was connected at call time, - /// and clears the session entry from storage. + /// `WalletDisconnected` event whenever a contract was bound at call time + /// (independent of credential), and clears the session entry from storage. Future disconnect() async { - final wasConnected = - _connectedCredentialId != null && _connectedContractId != null; final priorContractId = _connectedContractId; _connectedCredentialId = null; _connectedContractId = null; @@ -159,7 +157,7 @@ class FakePipelineKit implements OZSmartAccountWalletKitInterface { } catch (_) { // Non-critical — clearing is best-effort. } - if (wasConnected && priorContractId != null) { + if (priorContractId != null) { _events.emit( OZSmartAccountEventWalletDisconnected(contractId: priorContractId), ); @@ -197,7 +195,7 @@ class FakePipelineKit implements OZSmartAccountWalletKitInterface { Future requireConnected() async { final cid = _connectedCredentialId; final ctr = _connectedContractId; - if (cid == null || ctr == null) { + if (ctr == null) { throw SmartAccountWalletException.notConnected(); } return OZConnectedState(credentialId: cid, contractId: ctr); @@ -212,12 +210,33 @@ class FakePipelineKit implements OZSmartAccountWalletKitInterface { _connectedContractId = contractId; } + @override + Future setHeadlessConnectedState({required String contractId}) async { + _connectedCredentialId = null; + _connectedContractId = contractId; + } + @override OZTransactionOperations get transactionOperations => _transactionOperations; @override String? get contractId => _connectedContractId; + /// Mirrors the real kit's connection-state surface so headless-connect + /// tests can assert it directly. `true` when a contract address is bound, + /// covering both a passkey and a headless connection. + bool get isConnected => _connectedContractId != null; + + /// Mirrors the real kit's [OZSmartAccountKit.credentialId]: the bound + /// passkey credential ID, or `null` when disconnected or connected + /// headlessly. + String? get credentialId => _connectedCredentialId; + + /// Mirrors the real kit's [OZSmartAccountKit.isHeadless]: `true` exactly + /// when a contract address is bound while no passkey credential is present. + bool get isHeadless => + _connectedContractId != null && _connectedCredentialId == null; + @override OZExternalSignerManager get externalSigners => _externalSigners; @@ -321,6 +340,9 @@ class StubCredentialManager implements OZWalletCredentialManagerInterface { /// Test-only: returns the credential currently stored under /// [credentialId], or `null`. OZStoredCredential? peek(String credentialId) => _store[credentialId]; + + /// Test-only: every credential ID currently held in the store. + Iterable get storedCredentialIds => _store.keys; } /// Stub context-rule manager exposed for tests that need to inject diff --git a/test/unit/smartaccount/oz/oz_smart_account_events_test.dart b/test/unit/smartaccount/oz/oz_smart_account_events_test.dart index 1b44a4d5..b06981e2 100644 --- a/test/unit/smartaccount/oz/oz_smart_account_events_test.dart +++ b/test/unit/smartaccount/oz/oz_smart_account_events_test.dart @@ -728,6 +728,27 @@ void main() { expect(copied.credentialId, 'cr-new'); }); + test('testHeadlessConnected_equalityAndHashCode', () { + // Non-const instances so the identical() short-circuit in operator== does + // not fire and the contractId field comparison is exercised. + final event1 = + OZSmartAccountEventHeadlessConnected(contractId: 'C-HEADLESS'); + final event2 = + OZSmartAccountEventHeadlessConnected(contractId: 'C-HEADLESS'); + final event3 = + OZSmartAccountEventHeadlessConnected(contractId: 'C-OTHER'); + + expect(event1, equals(event2), + reason: 'Same contractId must be equal'); + expect(event1.hashCode, equals(event2.hashCode)); + expect(event1, isNot(equals(event3)), + reason: 'Different contractId must not be equal'); + expect(event1, equals(event1), + reason: 'Identical instance short-circuits to equal'); + expect(event1 == 'not-an-event', isFalse); + expect(event1.eventTypeName, 'HeadlessConnected'); + }); + test('testWalletDisconnected_equalityAndCopy', () { const event1 = OZSmartAccountEventWalletDisconnected(contractId: 'C1'); const event2 = OZSmartAccountEventWalletDisconnected(contractId: 'C1'); diff --git a/test/unit/smartaccount/oz/oz_smart_account_kit_test.dart b/test/unit/smartaccount/oz/oz_smart_account_kit_test.dart index 8986c24f..7cbc6f3b 100644 --- a/test/unit/smartaccount/oz/oz_smart_account_kit_test.dart +++ b/test/unit/smartaccount/oz/oz_smart_account_kit_test.dart @@ -448,6 +448,50 @@ void main() { expect(state.credentialId, equals(_credentialIdB64)); expect(state.contractId, equals(_validContractId)); }); + + test('setHeadlessConnectedState_marksHeadlessConnection_withoutCredential', + () async { + final kit = OZSmartAccountKit.create(config: _validConfig()); + + await kit.setHeadlessConnectedState(contractId: _validContractId); + + expect(kit.isConnected, isTrue); + expect(kit.isHeadless, isTrue); + expect(kit.credentialId, isNull); + expect(kit.contractId, equals(_validContractId)); + + // A headless connection still satisfies requireConnected, but the + // returned state carries no credential and reports isHeadless. + final state = await kit.requireConnected(); + expect(state.credentialId, isNull); + expect(state.isHeadless, isTrue); + expect(state.contractId, equals(_validContractId)); + }); + + test('connectionState_passkeyToHeadlessToPasskey_flipsFlags', () async { + final kit = OZSmartAccountKit.create(config: _validConfig()); + + await kit.setConnectedState( + credentialId: _credentialIdB64, + contractId: _validContractId, + ); + expect(kit.isConnected, isTrue); + expect(kit.isHeadless, isFalse); + expect(kit.credentialId, equals(_credentialIdB64)); + + await kit.setHeadlessConnectedState(contractId: _validContractId); + expect(kit.isConnected, isTrue); + expect(kit.isHeadless, isTrue); + expect(kit.credentialId, isNull); + + await kit.setConnectedState( + credentialId: _credentialIdB64, + contractId: _validContractId, + ); + expect(kit.isConnected, isTrue); + expect(kit.isHeadless, isFalse); + expect(kit.credentialId, equals(_credentialIdB64)); + }); }); group('default deployer', () { @@ -664,15 +708,18 @@ void main() { () async { final kit = OZSmartAccountKit.create(config: _validConfig()); - // why: drive 10 parallel callers each performing a connect + - // disconnect pair. The kit's lock serialises the state writes - // against the storage I/O so the futures must all complete - // without any caller deadlocking on the previous tail. + // why: drive 10 parallel callers, each performing a passkey connect, a + // headless re-connect, and a disconnect. The kit's lock serialises every + // state write, including setHeadlessConnectedState, against the storage + // I/O so the futures must all complete without any caller deadlocking on + // the previous tail, and each write the headless setter makes stays + // coherent under contention. final futures = List>.generate(10, (i) async { await kit.setConnectedState( credentialId: 'cred-$i', contractId: _validContractId, ); + await kit.setHeadlessConnectedState(contractId: _validContractId); await kit.disconnect(); }); diff --git a/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart b/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart index e335277f..de0ca10a 100644 --- a/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart +++ b/test/unit/smartaccount/oz/oz_wallet_create_pipeline_test.dart @@ -86,8 +86,8 @@ GetTransactionResponse _txSuccess({int ledger = 12345}) { return r; } -/// A populated [LedgerEntry] standing in for a visible contract instance; only -/// its non-null presence matters to the visibility poll. +/// A minimal non-null [LedgerEntry] standing in for a visible contract +/// instance; only its non-null presence matters to the visibility poll. LedgerEntry _visibleInstanceEntry() => LedgerEntry('', '', 0, null, null); void main() {