Skip to content

Commit 755633b

Browse files
Merge pull request #47 from Soneso/sa-hardening
Smart account hardening and web-target network error handling
2 parents bb8a0de + 2375c95 commit 755633b

74 files changed

Lines changed: 4341 additions & 1080 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
- Horizon and SEP network boundaries no longer leak Kotlin/JS connectivity
12+
errors. On Kotlin/JS the HTTP engine reports a failed connection as a
13+
`kotlin.Error` ("Fail to fetch"), which is a `Throwable` but not an
14+
`Exception`, so it escaped the `catch (Exception)` blocks in the Horizon
15+
request builders, `HorizonServer` submit/POST paths, `Page.getNextPage`, the
16+
SSE stream loop, the SEP-10 (`WebAuth`) and SEP-45 (`WebAuthForContracts`)
17+
challenge and token calls, and `Sep31Service.fromDomain`. These boundaries now
18+
catch `Throwable` and surface the connectivity failure as the documented
19+
exception type (`ConnectionErrorException`, `ChallengeRequestException`,
20+
`TokenSubmissionException`, `Sep45ChallengeRequestException`,
21+
`Sep45TokenSubmissionException`, `Sep31ConfigurationException`, ...) on every
22+
platform. Coroutine cancellation and platform-fatal errors now propagate
23+
instead of being wrapped or swallowed. Behavior for `Exception`-typed failures
24+
on JVM and native is unchanged.
25+
826
## [1.9.0] - 2026-07-14
927

1028
### Added

docs/smart-accounts/api-reference.md

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@ data class OZSmartAccountConfig(
273273
val storage: StorageAdapter = InMemoryStorageAdapter(),
274274
val externalWallet: ExternalWalletAdapter? = null,
275275
val externalEd25519Adapter: OZExternalEd25519SignerAdapter? = null,
276-
val maxContextRuleScanId: UInt = 50u
276+
val maxContextRuleScanId: UInt = 50u,
277+
val defaultPolicies: Map<String, SCValXdr> = emptyMap()
277278
)
278279
```
279280

@@ -295,6 +296,7 @@ data class OZSmartAccountConfig(
295296
- `externalWallet`: Optional wallet adapter (`ExternalWalletAdapter`) backing the adapter custody model for `SelectedSigner.Wallet` (G-address) signers. The kit injects it into `kit.externalSigners`.
296297
- `externalEd25519Adapter`: Optional Ed25519 adapter (`OZExternalEd25519SignerAdapter`) backing the adapter custody model for `SelectedSigner.Ed25519` signers (hardware wallet, HSM, remote signing service). The kit injects it into `kit.externalSigners`. See [External Signer Management](#external-signer-management).
297298
- `maxContextRuleScanId`: Upper bound on rule IDs to scan when iterating context rules (defaults to 50). Increase if the account has had many add/remove cycles.
299+
- `defaultPolicies`: Policies installed on a new wallet's Default context rule at deploy time, keyed by policy contract address (C...) with the policy's install parameters as the value (see `PolicyInstallParams.toScVal()`). Applied through the contract constructor by `createWallet` and `deployPendingCredential`; a per-call `policies` argument overrides it. Defaults to no policies. Maximum 5. See the `createWallet` `policies` parameter for the built-in policies' install constraints at deploy time.
298300

299301
### Platform-Specific Providers
300302

@@ -411,7 +413,8 @@ suspend fun createWallet(
411413
autoSubmit: Boolean = false,
412414
autoFund: Boolean = false,
413415
nativeTokenContract: String? = null,
414-
forceMethod: SubmissionMethod? = null
416+
forceMethod: SubmissionMethod? = null,
417+
policies: Map<String, SCValXdr>? = null
415418
): CreateWalletResult
416419
```
417420

@@ -423,6 +426,7 @@ Creates a new smart account wallet with WebAuthn passkey authentication.
423426
- `autoFund`: Whether to automatically fund the wallet after deployment (testnet only)
424427
- `nativeTokenContract`: Required if `autoFund` is true; the native token contract address
425428
- `forceMethod`: Optional override to force relayer or RPC submission (default: auto-detect based on config)
429+
- `policies`: Policies to install on the new wallet's Default context rule at deploy time (via the contract constructor), keyed by policy contract address (C...) with the policy's install parameters as the value (see `PolicyInstallParams.toScVal()`). When null (default), `OZSmartAccountConfig.defaultPolicies` is used; pass a map (including an empty one) to override that default. Validated before the passkey ceremony, so an invalid policy config fails without creating an orphaned credential. Maximum 5 policies. Note the built-in policies' own install rules apply against this Default rule and its single initial signer: a spending-limit policy installs only on CallContract rules and cannot be installed here, and a threshold must not exceed the signer count. A threshold of 1 installs and keeps the rule at 1-of-N as more signers are added; beyond that, constructor policies are primarily useful for custom policies.
426430

427431
**Returns**: `CreateWalletResult` containing credential ID, contract address, signed transaction XDR, optional transaction hash, and nickname
428432

@@ -623,7 +627,8 @@ suspend fun deployPendingCredential(
623627
autoSubmit: Boolean = true,
624628
autoFund: Boolean = false,
625629
nativeTokenContract: String? = null,
626-
forceMethod: SubmissionMethod? = null
630+
forceMethod: SubmissionMethod? = null,
631+
policies: Map<String, SCValXdr>? = null
627632
): DeployPendingResult
628633
```
629634

@@ -637,6 +642,7 @@ The kit's connected state and session are set before the deploy transaction is s
637642
- `autoFund`: Whether to fund the wallet after deployment via Friendbot (default: false, testnet only)
638643
- `nativeTokenContract`: Required if `autoFund` is true; the native token contract address
639644
- `forceMethod`: Optional override to force relayer or RPC submission (default: auto-detect based on config)
645+
- `policies`: Policies to install on the Default context rule at deploy time, keyed by policy contract address (C...). When null (default), `OZSmartAccountConfig.defaultPolicies` is used; pass a map (including an empty one) to override it. Constructor args are not part of the contract-address preimage, so the derived address is unchanged. Maximum 5 policies.
640646

641647
**Returns**: `DeployPendingResult` containing contract address, signed transaction XDR, and optional transaction hash
642648

@@ -1346,6 +1352,8 @@ Low-level method that adds a pre-registered WebAuthn passkey signer to a context
13461352
- `selectedSigners`: Optional multi-signer authorization (default: single-signer with the connected passkey).
13471353
- `forceMethod`: Optional override to force relayer or RPC submission (default: auto-detect based on config).
13481354

1355+
**Contract limit**: Signer key data (`publicKey` + `credentialId` combined) max 256 bytes.
1356+
13491357
**Returns**: `TransactionResult` indicating success or failure
13501358

13511359
**Throws**:
@@ -1890,6 +1898,8 @@ suspend fun addContextRule(
18901898
**Contract limits**:
18911899
- Max 15 signers per rule
18921900
- Max 5 policies per rule
1901+
- Name max 20 UTF-8 bytes
1902+
- External signer key data max 256 bytes
18931903

18941904
**Returns**: `TransactionResult`
18951905

@@ -2003,7 +2013,7 @@ Updates the name of a context rule.
20032013

20042014
**Parameters**:
20052015
- `id`: Context rule ID
2006-
- `name`: New rule name (must not be empty)
2016+
- `name`: New rule name (must not be empty; max 20 UTF-8 bytes)
20072017
- `selectedSigners`: Optional multi-signer authorization (default: single-signer with the connected passkey).
20082018
- `forceMethod`: Optional override to force relayer or RPC submission (default: auto-detect based on config).
20092019

@@ -2888,7 +2898,7 @@ val indexer = OZIndexerClient.forNetwork("Test SDF Network ; September 2015")
28882898

28892899
// Or with a custom URL
28902900
val indexer = OZIndexerClient(
2891-
indexerUrl = "https://smart-account-indexer.sdf-ecosystem.workers.dev",
2901+
indexerUrl = "https://testnet.mercurydata.app/rest/smart-account-indexer",
28922902
timeoutMs = 10000
28932903
)
28942904
```
@@ -3343,7 +3353,7 @@ object SmartAccountAuthPayloadCodec {
33433353
}
33443354
```
33453355

3346-
Codec for reading and writing `SmartAccountAuthPayload` to and from `SCValXdr`. Inner signer entries are sorted by lowercase-hex of their XDR-encoded keys for deterministic encoding. Signature bytes are verifier-dependent: WebAuthn and Policy entries are XDR-encoded `SCValXdr`; Ed25519 entries carry the raw 64-byte signature (no XDR wrapper).
3356+
Codec for reading and writing `SmartAccountAuthPayload` to and from `SCValXdr`. Inner signer entries are sorted in the Soroban host's ScMap key order (content order, length as tiebreaker), matching how the contract materializes the map. Signature bytes are verifier-dependent: WebAuthn and Policy entries are XDR-encoded `SCValXdr`; Ed25519 entries carry the raw 64-byte signature (no XDR wrapper).
33473357

33483358
- `read(signatureScVal)` — accepts `SCValXdr.Void` (returns an empty payload) or `SCValXdr.Map` (the full payload).
33493359
- `write(payload)` — builds the outer map (`context_rule_ids` then `signers`) and sorts the inner signer entries deterministically.
@@ -3569,7 +3579,7 @@ sealed class SmartAccountException(
35693579
> | 3002 | `CREDENTIAL_ALREADY_EXISTS` | `UnvalidatedContext` |
35703580
> | 3003 | `CREDENTIAL_INVALID` | `ExternalVerificationFailed` |
35713581
>
3572-
> When inspecting an error code, first check the exception type to determine which namespace it belongs to. The SDK does not parse or map contract error codes — it surfaces the raw `Error(Contract, #NNNN)` message inside the exception, and the consumer extracts and interprets the code. [`ContractErrorCodes`](#contracterrorcodes) is a consumer-side reference catalog for that interpretation; the full on-chain enum is defined by the smart-account contract source (see [`SmartAccountError`, `WebAuthnError`, and policy error enums in `OpenZeppelin/stellar-contracts`](https://github.com/OpenZeppelin/stellar-contracts)).
3582+
> When inspecting an error code, first check the exception type to determine which namespace it belongs to. For a contract error, the SDK surfaces the raw `Error(Contract, #NNNN)` message inside the exception; extract the numeric code and pass it to [`ContractErrorCodes.decode`](#contracterrorcodes) to resolve it to its defining contract and variant name (or match it against a constant). The full on-chain enum is defined by the smart-account contract source (see [`SmartAccountError`, `WebAuthnError`, and policy error enums in `OpenZeppelin/stellar-contracts`](https://github.com/OpenZeppelin/stellar-contracts)).
35733583
35743584
```kotlin
35753585
enum class SmartAccountErrorCode(val code: Int) {
@@ -3769,17 +3779,37 @@ sealed class IndexerException : SmartAccountException {
37693779

37703780
### ContractErrorCodes
37713781

3772-
Defined in `smartaccount/core/SmartAccountErrors.kt`. A **curated subset** of on-chain error codes from the OpenZeppelin smart-account contract, provided as a reference catalog for consumers. The SDK does not parse or map these codes — failed transactions surface the raw `Error(Contract, #NNNN)` message inside the exception, and the consumer matches the extracted code against these constants. Error code range: 3xxx.
3782+
Defined in `smartaccount/core/SmartAccountErrors.kt`. Named constants for the smart-account contract's own error enum (the codes a caller is most likely to branch on), plus `decode(code)`, which resolves any known code — smart account, WebAuthn, or a policy contractinto the contract and variant name that defined it. A failed transaction surfaces the raw `Error(Contract, #NNNN)` message inside the exception (typically `TransactionException.SimulationFailed`); extract the code and pass it to `decode`, or match it against a constant. Alternatively, pass a thrown `TransactionException`'s message directly to `decodeFromMessage`, which extracts and decodes the first known marker in one step. Error code range: 3xxx.
37733783

3774-
This object does not mirror the full on-chain enum. The smart-account contract additionally defines codes for context-rule lookup, auth-payload validation, external verification, WebAuthn parsing (3110–3119), and policy enforcement (3200–3227 across the simple-threshold, weighted-threshold, and spending-limit policies). See the contract source for the full list: [OpenZeppelin/stellar-contracts — `packages/accounts`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/accounts). Note that several values in the 3xxx range also exist in the SDK-side [`SmartAccountErrorCode`](#smartaccounterrorcode) enum with different meanings — the two are distinguished by the exception type they arrive through.
3784+
`decode` returns an `OZContractError` (`code`, `contract`, `name`) or `null` for an unknown code. It covers the full on-chain surface: `SmartAccountError` (3000–3016; 3001 unused), `WebAuthnError` (3110–3119), and the policy enums `SimpleThresholdError` (3200–3203), `WeightedThresholdError` (3210–3214), and `SpendingLimitError` (3220–3227). Variant names repeat across the policy enums, so `contract` disambiguates; `code` is globally unique. Note that several 3xxx values also exist in the SDK-side [`SmartAccountErrorCode`](#smartaccounterrorcode) enum with different meanings — the two are distinguished by the exception type they arrive through.
37753785

37763786
```kotlin
3787+
data class OZContractError(val code: Int, val contract: String, val name: String)
3788+
37773789
object ContractErrorCodes {
3778-
const val MATH_OVERFLOW = 3012 // Integer arithmetic overflow occurred in the contract
3779-
const val KEY_DATA_TOO_LARGE = 3013 // The key_data field on a signer exceeds the maximum allowed size
3780-
const val CONTEXT_RULE_IDS_LENGTH_MISMATCH = 3014 // The number of context rule IDs does not match the expected count
3781-
const val NAME_TOO_LONG = 3015 // A name field (e.g. context rule name) exceeds the maximum allowed length
3782-
const val UNAUTHORIZED_SIGNER = 3016 // The signer is not authorized to sign the given context rule
3790+
// Smart account contract (SmartAccountError, 3000-3016; 3001 unused)
3791+
const val CONTEXT_RULE_NOT_FOUND = 3000
3792+
const val UNVALIDATED_CONTEXT = 3002
3793+
const val EXTERNAL_VERIFICATION_FAILED = 3003
3794+
const val NO_SIGNERS_AND_POLICIES = 3004
3795+
const val PAST_VALID_UNTIL = 3005
3796+
const val SIGNER_NOT_FOUND = 3006
3797+
const val DUPLICATE_SIGNER = 3007
3798+
const val POLICY_NOT_FOUND = 3008
3799+
const val DUPLICATE_POLICY = 3009
3800+
const val TOO_MANY_SIGNERS = 3010
3801+
const val TOO_MANY_POLICIES = 3011
3802+
const val MATH_OVERFLOW = 3012
3803+
const val KEY_DATA_TOO_LARGE = 3013
3804+
const val CONTEXT_RULE_IDS_LENGTH_MISMATCH = 3014
3805+
const val NAME_TOO_LONG = 3015
3806+
const val UNAUTHORIZED_SIGNER = 3016
3807+
3808+
/** Resolves any known contract error code into its contract and variant, or null. */
3809+
fun decode(code: Int): OZContractError?
3810+
3811+
/** Extracts and decodes the first known Error(Contract, #NNNN) marker from an error message, or null. */
3812+
fun decodeFromMessage(message: String?): OZContractError?
37833813
}
37843814
```
37853815

docs/smart-accounts/onboarding.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ You can add rules that apply only to specific scenarios. For example, a rule tha
9494
Each context rule stores:
9595
- An ID (`u32`)
9696
- A type: `Default`, `CallContract(address)`, or `CreateContract(wasmHash: ByteArray)`. `wasmHash` is a 32-byte `ByteArray` containing the SHA-256 hash of a compiled smart contract binary. `CreateContract` matches contract creation operations targeting that specific binary.
97-
- A name (human-readable string)
97+
- A name (human-readable string, up to 20 UTF-8 bytes)
9898
- A list of signers (up to 15)
9999
- A list of policies (up to 5)
100100
- An optional expiration ledger number. After that ledger is reached, the rule no longer authorizes anything, useful for temporary authorization grants.
@@ -170,6 +170,8 @@ kit.policyManager.addPolicy(
170170

171171
The install parameters are policy-specific. Your custom policy contract defines what parameters it expects during installation.
172172

173+
Policies can also be installed at deploy time on the wallet's Default rule, instead of being added afterward. Set `OZSmartAccountConfig.defaultPolicies`, or pass a per-call `policies` map to `createWallet` / `deployPendingCredential`, keyed by policy contract address with the policy's install parameters as the value. The kit passes them through the contract constructor, so the new wallet starts with those policies already enforced; a per-call argument overrides the config default. Because the Default rule starts with a single signer and the spending-limit policy only installs on CallContract rules, this is primarily useful for a threshold of 1 (which keeps the rule at 1-of-N as more signers are added) or custom policies.
174+
173175
A typical setup involves 3-5 deployed contracts: the smart account (one per user), a WebAuthn verifier (shared across all accounts on the network), and 1-3 policy contracts (also shared).
174176

175177
---

docs/smart-accounts/scf/oz-sm-architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Signer encoding follows the standard Soroban enum serialization for the on-chain
141141

142142
**Signature normalization**: WebAuthn produces DER-encoded secp256r1 signatures. The KMP SDK converts these to compact 64-byte format with low-S enforcement, as required by the on-chain verifier.
143143

144-
**Auth entry signing**: The KMP SDK computes `SHA-256(XDR(HashIDPreimage::SorobanAuthorization))` as the payload hash. Signature values use double XDR encoding (encode SCVal to bytes, wrap in SCVal::Bytes). SCVal map keys are sorted by XDR-encoded byte representation for deterministic ordering.
144+
**Auth entry signing**: The KMP SDK computes `SHA-256(XDR(HashIDPreimage::SorobanAuthorization))` as the payload hash. Signature values use double XDR encoding (encode SCVal to bytes, wrap in SCVal::Bytes). SCVal map keys are sorted in the Soroban host's ScMap key order (content order, length as tiebreaker) for deterministic encoding.
145145

146146
**Storage security**: Android uses AES-256-GCM encryption backed by the Android Keystore. Apple platforms use the system Keychain. Web uses IndexedDB. Stored data contains only public keys and session metadata, never secret keys.
147147

skills/kmp-stellar-sdk.zip

540 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)