Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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%)
Expand Down
15 changes: 15 additions & 0 deletions lib/src/smartaccount/oz/oz_constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ class OZConstants {
/// wallet.
static const int friendbotReserveXlm = 5;

/// 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;

Expand Down
9 changes: 9 additions & 0 deletions lib/src/smartaccount/oz/oz_internal_pipeline_interfaces.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> 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
Expand Down
27 changes: 27 additions & 0 deletions lib/src/smartaccount/oz/oz_smart_account_events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -506,6 +530,9 @@ class OZSmartAccountEventEmitter {
if (E == OZSmartAccountEventWalletConnected) {
return 'WalletConnected';
}
if (E == OZSmartAccountEventHeadlessConnected) {
return 'HeadlessConnected';
}
if (E == OZSmartAccountEventWalletDisconnected) {
return 'WalletDisconnected';
}
Expand Down
68 changes: 52 additions & 16 deletions lib/src/smartaccount/oz/oz_smart_account_kit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -217,30 +217,46 @@ 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
String? get contractId => _contractId;

// 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<void> setConnectedState({
Expand All @@ -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<void> setHeadlessConnectedState({required String contractId}) async {
await _withLock<void>(() {
_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<OZConnectedState> requireConnected() async {
return _withLock<OZConnectedState>(() {
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.',
Expand Down
16 changes: 12 additions & 4 deletions lib/src/smartaccount/oz/oz_smart_account_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -339,21 +339,29 @@ 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({
required this.credentialId,
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;
Expand Down
100 changes: 91 additions & 9 deletions lib/src/smartaccount/oz/oz_transaction_operations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,19 @@ class OZTransactionOperations {
final credentialId = connected.credentialId;
final contractId = connected.contractId;

// 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 '
'pipeline with explicit selectedSigners for headless operations.',
);
}

final deployer = await _kit.getDeployer();
final deployerAccount = await _fetchAccount(deployer.accountId);

Expand Down Expand Up @@ -808,7 +821,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<String> fundWallet({
required String nativeTokenContract,
OZSubmissionMethod? forceMethod,
Expand All @@ -829,14 +843,9 @@ 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,
);
// 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);

Expand Down Expand Up @@ -1353,6 +1362,79 @@ 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.rpcVisibilityPollIntervalMs] /
/// [OZConstants.rpcVisibilityTimeoutSeconds] budgets and are
/// 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
/// [SmartAccountTransactionException.timeout] when the account does
/// not become visible within [timeout].
@visibleForTesting
Future<void> waitForAccountVisibleToRpc(
String accountId,
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 account = await _kit.sorobanServer.getAccount(accountId);
if (account != null) {
return;
}
} 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)) {
break;
}
await _cancellableDelay(pollInterval, cancelToken);
}
final timeoutLabel = timeout.inSeconds >= 1
? '${timeout.inSeconds}s'
: '${timeout.inMilliseconds}ms';
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,
);
}

/// Fetches an account from Soroban RPC, throwing
/// [SmartAccountTransactionException.submissionFailed] when the account is not found
/// or when the RPC call fails.
Expand Down
Loading