Skip to main content

Architecture

Ports & adapters​

The SDK core is pure logic with no platform dependencies — it runs and is unit-tested on plain JVM/Linux (and Linux Swift). Everything platform-specific is a port the host implements and injects at construction time.

┌────────────────────────────── your app (UI) ──────────────────────────────┐
│ │
│ Wallet.create(config, ports) │
│ │ │
│ ▼ │
│ ┌────────────────────────── Wallet facade ───────────────────────────┐ │
│ │ credentials · issuance · presentation · proximity │ │
│ │ reader · transactions │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ core modules (pure): cbor · sdjwt · mdoc · openid4vci · │
│ openid4vp · trust · statuslist · credential-store · │
│ proximity · txlog │
│ │
│ ports you inject ▸ SecureArea · StorageDriver · │
│ HttpTransport · WalletClock · Rng · WalletLogger · │
│ TransactionLogStore · WalletAttestationProvider │
│ │
│ per-session port ▸ ProximityTransport (present / read) │
└───────────────────────────────────────────────────────────────────────────┘

The ports​

The full SPI lives in the wallet-api module (Kotlin com.hopae.eudi.wallet.spi, Swift WalletAPI). WalletPorts bundles them; three are required and the rest carry defaults.

PortRequiredResponsibilityTypical adapter
SecureAreayesCreate keys, sign, key-agree, attest, deleteAndroid Keystore / iOS Secure Enclave (software for tests)
StorageDriveryesPersist bytes by collection/key, with a txEncrypted file / DataStore / Keychain
HttpTransportyesExecute HTTP, honouring followRedirectsOkHttp / URLSession
WalletClocknoCurrent time (default: system clock)WalletClock.System / SystemClock
RngnoRandom bytes (default: platform secure RNG)Rng.Default / SystemRng
WalletLoggernoStructured log sink (default: none)App-specific (logcat / os_log / file)
TransactionLogStorenoAppend-only audit persistenceEncrypted store (in-memory default)
WalletAttestationProvidernoWallet Provider link (WUA + key attestation)Backend client

SecureArea is the crypto boundary: every operation that touches a private key (sign, keyAgreement, attestation) goes through it, while public-key verification, hashing, and encoding stay in the pure core and run anywhere (including Linux CI). secureAreas is a list — the first is the default; extras let one wallet span, say, a StrongBox area and a TEE area.

ProximityTransport is a per-session port, not a WalletPorts field: you pass a fresh one to each wallet.proximity.present(transport) / wallet.reader.read(transport, …) call, because a BLE/NFC channel is bound to a single in-person exchange.

The SDK owns credential, key, issuance, and presentation lifecycle. The host only supplies the thin capabilities above — there is no DI framework, just constructor injection through Wallet.create(config, ports).

Adapters → SPI​

The android/ modules are ready-made adapters (Maven group com.hopae.eudi.android, distinct from the SDK's com.hopae.eudi so artifacts never clash). Depend on them directly, or read them as a reference implementation for your own platform:

Adapter (android/)ImplementsModule
AndroidKeystoreSecureAreaSecureAreacore
FileStorageDriverStorageDrivercore
OkHttpTransportHttpTransportcore
FileTransactionLogStoreTransactionLogStorecore
BleGattServerTransport / BleGattClientTransportProximityTransportproximity
WalletProviderAttestationWalletAttestationProviderattestation

WalletClock / Rng use the SDK defaults; WalletLogger is intentionally app-supplied (real logging is app-specific).

The ios/ package (SwiftPM EudiWalletApple) is the 1:1 twin, implementing the same ports against Apple frameworks:

Adapter (ios/)ImplementsModule
SecureEnclaveSecureAreaSecureAreaAppleCore
KeychainStorageDriverStorageDriverAppleCore
URLSessionTransportHttpTransportAppleCore
FileTransactionLogStoreTransactionLogStoreAppleCore
BlePeripheralTransport / BleCentralTransportProximityTransportAppleProximity
WalletProviderAttestationWalletAttestationProviderAppleAttestation

Rolling your own. An adapter qualifies by passing the shared contract test suites shipped in the test kit (SecureAreaContract.verify(area), StorageDriverContract.verify(driver)) — the same checks run on Linux CI against SoftwareSecureArea / InMemoryStorageDriver and in the device lab against the real adapters. See Getting started.

Presets vs. demo. android/ and ios/ are the reusable adapter layers your app ships against; demo/ (Jetpack Compose) and demo-ios/ (SwiftUI, Axle Wallet) are full debug wallets that assemble those adapters end to end — see Android adapters & demo and iOS adapters & demo.

Modules​

Each concern is a separate module so it can be tested in isolation and reused:

cbor (CBOR/COSE) · sdjwt (SD-JWT VC, JOSE) · mdoc (ISO 18013-5) · openid4vci · openid4vp · trust (X.509 PKIX) · statuslist (Token Status List) · credential-store · proximity (18013-5 session) · txlog (audit) · wallet (the facade that assembles them).

Your app compiles against exactly two modules: wallet (the facade — Wallet, the six services, WalletConfig, WalletPorts) and wallet-api (the SPI you implement). The concern modules above are internal wiring — Wallet.create builds them for you, and you never reference them directly.

Lifecycle & threading​

Wallet is multi-instance and thread-safe: share one instance across coroutines / tasks, or run several independent wallets in one process — there is no global state.

Wallet.create opens a CoroutineScope(SupervisorJob() + Dispatchers.Default) that owns every in-flight issuance, presentation, and proximity session. Wallet is AutoCloseable; close() cancels that scope (tearing down any running sessions) and is idempotent — safe to call on logout or teardown, and a no-op afterwards. Because the scope uses a SupervisorJob, one failed session never cancels its siblings. An individual in-flight session can also be stopped on its own with session.cancel().

Sessions as state machines​

Issuance, presentation, and proximity are suspending state machines. The flow pauses at interaction points (browser authorization, tx_code, consent) and resumes when the app calls back — a StateFlow in Kotlin, an AsyncStream in Swift.

start(...) → Processing → [pause: AuthorizationRequired / TxCodeRequired / RequestResolved]
→ [app resumes] → Submitting → Completed | Failed | Declined