Skip to content

OTPFlow callback pattern#93

Open
tomas-martins-crossmint wants to merge 17 commits into
mainfrom
tomas/wal-10193-otpflow-callback-pattern
Open

OTPFlow callback pattern#93
tomas-martins-crossmint wants to merge 17 commits into
mainfrom
tomas/wal-10193-otpflow-callback-pattern

Conversation

@tomas-martins-crossmint
Copy link
Copy Markdown
Contributor

@tomas-martins-crossmint tomas-martins-crossmint commented May 26, 2026

The previous OTP pattern exposed isOTPRequired, provideOTP, and cancelOTP directly on CrossmintSDK, requiring callers to observe state changes and coordinate the flow manually. This replaces it with a flow object passed to a callback: when a wallet operation needs email authentication, the SDK calls onAuthRequired with an OTPFlow the caller drives directly. The wallet operation suspends until verifyOTP or cancel is called.

let wallet = try await sdk.crossmintWallets.createWallet(
    chain: EVMChain.baseSepolia,
    recovery: EVMSigners.email("[email protected]"),
    onAuthRequired: { flow in
        self.pendingOTPFlow = flow
    }
)

// Your OTP UI drives the flow
struct OTPView: View {
    let flow: OTPFlow
    @State private var code = ""

    var body: some View {
        VStack {
            Text("Code sent to \(flow.email)")
            TextField("Code", text: $code)
            Button("Verify") { Task { try await flow.verifyOTP(code) } }
            Button("Resend") { Task { try? await flow.sendOTP() } }
            Button("Cancel") { flow.cancel() }
        }
    }
}

Passing pendingOTPFlow to sheet(item:) works out of the box since OTPFlow conforms to Identifiable via a UUID, so each new signing operation presents a fresh sheet.

Changes

  • OTPFlow (Web): new public struct with email, sendOTP, verifyOTP, cancel, and a UUID id.
  • CrossmintTEE: signTransaction now accepts onAuthRequired. On a new device it calls the closure with an OTPFlow and suspends until the user verifies or cancels.
  • CrossmintWallets: getWallet and createWallet gain an optional onAuthRequired parameter (default nil). Existing callers are unaffected.
  • DefaultCrossmintWallets: new resolveEmailSigner helper that rebuilds an email signer with the provided callback when needed.
  • Email signers (EVMEmailSigner, SolanaEmailSigner, StellarEmailSigner): inits narrowed to internal; onAuthRequired is typed @MainActor.
  • Demo app: OTPValidatorView disables input after successful verification to prevent double-submission. AppState uses the new onAuthRequired parameter style.
  • Tests: five new tests in CrossmintTEEOTPFlowTests covering success, cancel, resend, no-callback error, and email forwarding. Setup extracted to CrossmintTEEOTPFlowFixture.

…ts, extract loadSignerIfNeeded, apiKey factory, OTP callback log
…params

Remove WalletSigner struct (naming conflict with parallel worktree branch that
uses WalletSigner as a plain enum). Preserve OTPFlow wiring by adding
onAuthRequired as an optional parameter on the existing getWallet/createWallet
protocol methods, threaded through resolveEmailSigner in DefaultCrossmintWallets.

Also fix afeight-review blockers: flatten performSign duplication in
executeSignTransaction, extract makeSendOTPClosure/makeVerifyOTPClosure from
makeOTPFlow, move otpCallbackFired log inside the dispatch task, extract
startSigningAndCaptureFlow helper in tests, fix try? on sendOTP.
@tomas-martins-crossmint tomas-martins-crossmint marked this pull request as ready for review May 26, 2026 23:56
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 27, 2026

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
Sources/Web/CrossmintTEE.swift:427-433
The `Task { await onAuthRequired(flow) }` fires the callback in an unstructured task that runs concurrently with (and independently of) the stream loop. If the outer sign task is cancelled while this unstructured task is still queued, the stream loop will throw `CancellationError` first, `waitForOTP` returns, the caller cleans up (`pendingOTPFlow = nil`), and only then does the orphaned task run `onAuthRequired` — setting `pendingOTPFlow = flow` with a dead handle. The OTP sheet would appear with a flow whose `verifyOTP`/`cancel` closures are writing to an already-abandoned stream continuation (no-ops), leaving the user stuck.

Calling `await onAuthRequired(flow)` directly is safe here: `onAuthRequired` is expected to be a quick store-and-return operation, the stream and its closures are already set up before the call, and awaiting ensures the callback completes (and any cleanup `pendingOTPFlow = nil` runs) within structured concurrency before the loop begins.

```suggestion
        let flow = makeOTPFlow(jwt: jwt, email: email, continuation: streamContinuation)
        Logger.tee.debug(LogEvents.otpCallbackFired)
        await onAuthRequired(flow)
        do {
            for try await _ in stream {}
```

### Issue 2 of 2
Tests/WebTests/Helpers/OTPFlowTestHelpers.swift:79-84
**Unsynchronised `@unchecked Sendable` capture class**

`OTPEmailCapture` writes `_value` from `onAuthRequired` (dispatched on `@MainActor`) and reads it from the test body. In this specific test both operations happen on the main actor (the test struct is `@MainActor` and the callback is `@MainActor`), so it works in practice. However, marking the class `@unchecked Sendable` without any synchronisation mechanism (lock, actor, or `nonisolated(unsafe)`) is fragile: if the test is ever moved off `@MainActor` or the callback's isolation changes, this silently becomes a data race. Consider making it an `actor` or using `nonisolated(unsafe) var _value`.

Reviews (1): Last reviewed commit: "Narrow PR to TEE/OTP mechanism, move onA..." | Re-trigger Greptile

Comment on lines +427 to +433
let flow = makeOTPFlow(jwt: jwt, email: email, continuation: streamContinuation)
Task {
Logger.tee.debug(LogEvents.otpCallbackFired)
await onAuthRequired(flow)
}
do {
let otp = try await withCheckedThrowingContinuation { continuation in
self.otpContinuation?.resume(throwing: Error.newerSignatureRequested)
self.otpContinuation = continuation
self.isOTPRequired = true
}
Logger.tee.debug(LogEvents.otpReceived)
return otp
} catch CrossmintTEE.Error.userCancelled {
Logger.tee.warn(LogEvents.otpCancelled)
throw .userCancelled
} catch Error.newerSignatureRequested {
Logger.tee.warn(LogEvents.otpSuperseded)
throw .newerSignatureRequested
for try await _ in stream {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The Task { await onAuthRequired(flow) } fires the callback in an unstructured task that runs concurrently with (and independently of) the stream loop. If the outer sign task is cancelled while this unstructured task is still queued, the stream loop will throw CancellationError first, waitForOTP returns, the caller cleans up (pendingOTPFlow = nil), and only then does the orphaned task run onAuthRequired — setting pendingOTPFlow = flow with a dead handle. The OTP sheet would appear with a flow whose verifyOTP/cancel closures are writing to an already-abandoned stream continuation (no-ops), leaving the user stuck.

Calling await onAuthRequired(flow) directly is safe here: onAuthRequired is expected to be a quick store-and-return operation, the stream and its closures are already set up before the call, and awaiting ensures the callback completes (and any cleanup pendingOTPFlow = nil runs) within structured concurrency before the loop begins.

Suggested change
let flow = makeOTPFlow(jwt: jwt, email: email, continuation: streamContinuation)
Task {
Logger.tee.debug(LogEvents.otpCallbackFired)
await onAuthRequired(flow)
}
do {
let otp = try await withCheckedThrowingContinuation { continuation in
self.otpContinuation?.resume(throwing: Error.newerSignatureRequested)
self.otpContinuation = continuation
self.isOTPRequired = true
}
Logger.tee.debug(LogEvents.otpReceived)
return otp
} catch CrossmintTEE.Error.userCancelled {
Logger.tee.warn(LogEvents.otpCancelled)
throw .userCancelled
} catch Error.newerSignatureRequested {
Logger.tee.warn(LogEvents.otpSuperseded)
throw .newerSignatureRequested
for try await _ in stream {}
let flow = makeOTPFlow(jwt: jwt, email: email, continuation: streamContinuation)
Logger.tee.debug(LogEvents.otpCallbackFired)
await onAuthRequired(flow)
do {
for try await _ in stream {}
Prompt To Fix With AI
This is a comment left during a code review.
Path: Sources/Web/CrossmintTEE.swift
Line: 427-433

Comment:
The `Task { await onAuthRequired(flow) }` fires the callback in an unstructured task that runs concurrently with (and independently of) the stream loop. If the outer sign task is cancelled while this unstructured task is still queued, the stream loop will throw `CancellationError` first, `waitForOTP` returns, the caller cleans up (`pendingOTPFlow = nil`), and only then does the orphaned task run `onAuthRequired` — setting `pendingOTPFlow = flow` with a dead handle. The OTP sheet would appear with a flow whose `verifyOTP`/`cancel` closures are writing to an already-abandoned stream continuation (no-ops), leaving the user stuck.

Calling `await onAuthRequired(flow)` directly is safe here: `onAuthRequired` is expected to be a quick store-and-return operation, the stream and its closures are already set up before the call, and awaiting ensures the callback completes (and any cleanup `pendingOTPFlow = nil` runs) within structured concurrency before the loop begins.

```suggestion
        let flow = makeOTPFlow(jwt: jwt, email: email, continuation: streamContinuation)
        Logger.tee.debug(LogEvents.otpCallbackFired)
        await onAuthRequired(flow)
        do {
            for try await _ in stream {}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +79 to +84
for await flow in flowStream { receivedFlow = flow }
return (signTask, receivedFlow)
}
}

final class OTPEmailCapture: @unchecked Sendable {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unsynchronised @unchecked Sendable capture class

OTPEmailCapture writes _value from onAuthRequired (dispatched on @MainActor) and reads it from the test body. In this specific test both operations happen on the main actor (the test struct is @MainActor and the callback is @MainActor), so it works in practice. However, marking the class @unchecked Sendable without any synchronisation mechanism (lock, actor, or nonisolated(unsafe)) is fragile: if the test is ever moved off @MainActor or the callback's isolation changes, this silently becomes a data race. Consider making it an actor or using nonisolated(unsafe) var _value.

Prompt To Fix With AI
This is a comment left during a code review.
Path: Tests/WebTests/Helpers/OTPFlowTestHelpers.swift
Line: 79-84

Comment:
**Unsynchronised `@unchecked Sendable` capture class**

`OTPEmailCapture` writes `_value` from `onAuthRequired` (dispatched on `@MainActor`) and reads it from the test body. In this specific test both operations happen on the main actor (the test struct is `@MainActor` and the callback is `@MainActor`), so it works in practice. However, marking the class `@unchecked Sendable` without any synchronisation mechanism (lock, actor, or `nonisolated(unsafe)`) is fragile: if the test is ever moved off `@MainActor` or the callback's isolation changes, this silently becomes a data race. Consider making it an `actor` or using `nonisolated(unsafe) var _value`.

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant