Deploying FastAPI on AWS App Runner: A Production-Ready Guide with CI/CD
Marcelo Bittencourt | Jan 28, 2026
Traditional blockchain wallets require users to manage private keys — a major barrier to mainstream adoption. Lost keys mean lost funds, and many security incidents originate from poor key-handling practices.
In this article, we demonstrate how to build a smart wallet powered by passkeys (WebAuthn) for authentication and transaction signing on the Stellar / Soroban blockchain. By leveraging biometric authentication (Touch ID, Face ID, Windows Hello), we enable a seamless, secure user experience — without requiring private keys.
What You’ll Learn
For Node.js applications
For smart contracts
soroban-smart-wallet-poc/
├── apps/
│ ├── backend/ # Express.js API server
│ └── web/ # React frontend
├── contracts/
│ ├── factory/ # Wallet factory contract
│ └── smart-wallet/ # WebAuthn-enabled smart walletCode language: PHP (php)
# Clone repository
git clone https://github.com/CheesecakeLabs/soroban-smart-wallet-poc
cd soroban-smart-wallet-poc
# Install dependencies
npm installCode language: PHP (php)
| Component | Description |
| Backend | Express.js API for WebAuthn and Soroban interactions |
| Web | React frontend for wallet operations |
| Contracts | Soroban smart contracts (Factory + Smart Wallet) |
The smart wallet contract must be deployed first as the factory requires its WASM hash.
Configure the source account:
Update the SOURCE_ACCOUNT under contracts/smart-wallet/ to be the recovery account. This account will later be used to enable users to add new passkeys to their wallets (for lost-access recovery).
cd contracts/smart-wallet
<em># Build the contract</em>
make build
<em># Upload the WASM to the network</em>
make uploadCode language: PHP (php)
Important: Note the returned WASM hash, you’ll need it for the factory contract.
cd contracts/factory
Update the following in the factory Makefile:
– SOURCE_ACCOUNT: Can be the same as the smart wallet’s source account
– WASM_HASH: The hash obtained from the smart wallet upload
<em># Build the factory contract</em>
make build
Code language: HTML, XML (xml)
<em># Deploy to the network</em>
make deployCode language: HTML, XML (xml)
Important: Note the returned contract_id for backend configuration.
Create .env file in apps/backend/:
# Server Configuration
PORT=3000
# WebAuthn Configuration
WEBAUTHN_RP_NAME="Soroban Smart Wallet POC"
WEBAUTHN_RP_ORIGIN="http://localhost:5173"
# Stellar/Soroban Configuration
STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"
STELLAR_MAX_FEE="10000000"
# Contract Configuration
WALLET_FACTORY_CONTRACT_ID="C..." # Factory contract ID from deployment
NATIVE_TOKEN_CONTRACT_ID="CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
# Wallet Configuration
OPEX_WALLET_SECRET_KEY="S..." # Sponsors fees and funds wallets (must be funded)
RECOVERY_WALLET_SECRET_KEY="S..." # Recovery account secret key (starts with S)Code language: PHP (php)
| Variable | Description |
|---|---|
| WALLET_FACTORY_CONTRACT_ID | The deployed factory contract address |
| OPEX_WALLET_SECRET_KEY | Secret key of the wallet that sponsors transaction fees and funds new wallets. Must be a funded testnet wallet |
| RECOVERY_WALLET_SECRET_KEY | Secret key, its public key is set as recovery signer on new wallets. Enables passkey rotation for lost access |
WebAuthn requires a challenge–response model in which the server generates and validates cryptographic challenges.
WebAuthn uses a challenge-response authentication model that requires coordination between client and server:

The WebAuthn protocol requires:
This leads to a two-endpoint pattern for any operation requiring user authentication:
| Pattern | Options Endpoint | Action Endpoint |
|---|---|---|
| Wallet Creation | GET /api/create-wallet-options/:email | POST /api/create-wallet |
| Transfer | GET /api/transfer-options | POST /api/transfer |
| Sign In | GET /api/sign-in-options/:email | POST /api/sign-in |
| Wallet Recovery | GET /api/recover-wallet-options/:email | POST /api/recover-wallet |
Wallet creation uses the WebAuthn registration flow to create a new passkey and deploy a smart wallet.

The server generates WebAuthn registration options with a unique challenge:
<em>// apps/backend/src/helpers/webauthn/registration/index.ts</em>
<strong>async</strong> generateOptions(input: WebAuthnRegistrationGenerateOptionsInput): Promise<string> {
<strong>const</strong> { userIdentifier, userPasskeys, device } = input;
<strong>const</strong> relyingPartyName = getValueFromEnv("WEBAUTHN_RP_NAME");
<strong>const</strong> relyingPartyId = <strong>new</strong> URL(getValueFromEnv("WEBAUTHN_RP_ORIGIN")).hostname;
<strong>const</strong> identifier = userIdentifier.toLowerCase().trim();
<strong>const</strong> challenge = <strong>this</strong>.webauthnChallengeService.createChallenge();
<strong>const</strong> options = <strong>await</strong> generateRegistrationOptions({
rpName: relyingPartyName,
rpID: relyingPartyId,
userID: base64url.toBuffer(identifier) <strong>as</strong> Uint8Array<ArrayBuffer>,
userName: identifier,
userDisplayName: `${relyingPartyName} | ${device || "My Device"}`,
challenge: challenge,
supportedAlgorithmIDs: [-7], <em>// ES256 (secp256r1)</em>
attestationType: "none",
excludeCredentials: userPasskeys.map((passkey) <strong>=></strong> ({
id: passkey.credentialId,
transports: passkey.transports?.split(","),
})),
authenticatorSelection: {
authenticatorAttachment: "platform",
residentKey: "preferred",
userVerification: "required",
},
timeout: 120000,
});
<em>// Store challenge linked to user identifier for later validation</em>
<strong>this</strong>.webauthnChallengeService.storeChallenge(identifier, options.challenge);
<strong>return</strong> JSON.stringify(options);
}Code language: HTML, XML (xml)
Key configuration:
The frontend triggers the browser’s WebAuthn API to create a new passkey:
// apps/web/src/services/create-wallet/index.ts
async handle(input: CreateWalletInput): Promise<CreateWalletOutput> {
const { email } = input;
// 1. Get registration options from server (challenge generated by server)
const { data: registerOptions } = await http.get(
`/api/create-wallet-options/${email}`
);
const optionsJSON = JSON.parse(registerOptions.options_json);
// 2. Start WebAuthn registration (triggers passkey prompt)
const { rawResponse: createPasskeyResponse } =
await this.webauthnService.createPasskey({ optionsJSON });
// 3. Complete registration on the server (challenge validation)
const { data: registerResult } = await http.post(`/api/create-wallet`, {
email,
registration_response_json: JSON.stringify(createPasskeyResponse),
});
return registerResult;
}
The startRegistration method from @simplewebauthn/browser pops up the system passkey prompt:
// apps/web/src/interfaces/webauthn/index.ts
async createPasskey(input: WebAuthnCreatePasskeyInput): Promise<WebAuthnCreatePasskeyResult> {
this.checkAvailability();
const rawResponse = await this.webAuthnClient.startRegistration({
optionsJSON: input.optionsJSON,
});
return {
rawResponse,
credentialId: rawResponse.id,
};
}Code language: JavaScript (javascript)
The server validates the WebAuthn response and deploys the smart wallet:
<em>// apps/backend/src/api/create-wallet.ts</em>
<strong>async</strong> handle(payload: { email: string; registrationResponseJSON: string }) {
<strong>const</strong> { email, registrationResponseJSON } = payload;
<em>// 1. Verify challenge resolution</em>
<strong>const</strong> challengeResult = <strong>await</strong> <strong>this</strong>.webauthnRegistrationHelper.complete({
userIdentifier: email,
userPasskeys: [],
registrationResponseJSON,
});
<strong>if</strong> (!challengeResult) <strong>throw</strong> <strong>new</strong> Error("Unable to complete passkey registration");
<em>// 2. Create user and store passkey credentials</em>
<strong>const</strong> newUser = <strong>await</strong> <strong>this</strong>.userRepository.createUser({
email,
walletContractAddress: null,
});
<strong>const</strong> newPasskey = challengeResult.newPasskey;
<strong>await</strong> <strong>this</strong>.passkeyRepository.createPasskey({
userId: newUser.userId,
...newPasskey,
});
<em>// 3. Derive deterministic salt from email</em>
<strong>const</strong> saltBytes = <strong>new</strong> Uint8Array(32);
<strong>const</strong> identifierBytes = <strong>new</strong> TextEncoder().encode(email.toLowerCase().trim());
saltBytes.set(identifierBytes.slice(0, Math.min(32, identifierBytes.length)));
<strong>const</strong> hashedSalt = hash(Buffer.from(saltBytes));
<em>// 4. Deploy smart wallet via factory</em>
<strong>const</strong> args: xdr.ScVal[] = [
ScConvert.bufferToScVal(hashedSalt),
ScConvert.addressToScVal(<strong>this</strong>.recoveryWalletPublicKey),
ScConvert.hexPublicKeyToScVal(newPasskey.credentialHexPublicKey),
];
<strong>const</strong> { tx } = <strong>await</strong> <strong>this</strong>.sorobanService.simulateContractOperation({
contractId: <strong>this</strong>.factoryContractId,
method: "deploy",
args,
});
<strong>const</strong> preparedTx = <strong>await</strong> <strong>this</strong>.sorobanService.prepareTransaction(tx);
<strong>const</strong> signedTx = <strong>await</strong> <strong>this</strong>.sorobanService.signTransactionWithSourceAccount(preparedTx);
<strong>const</strong> sentTx = <strong>await</strong> <strong>this</strong>.sorobanService.sendTransaction(signedTx);
<em>// 5. Compute and store wallet address</em>
<strong>const</strong> expectedAddress = <strong>this</strong>.computeWalletAddress(email);
<strong>await</strong> <strong>this</strong>.userRepository.updateUser(newUser.userId, {
walletContractAddress: expectedAddress,
});
<strong>return</strong> {
walletAddress: expectedAddress,
transactionHash: sentTx.txHash,
};
}Code language: HTML, XML (xml)
If validation succeeds, we guarantee:
The transfer flow uses WebAuthn authentication (not registration) to sign a blockchain transaction with an existing passkey.
| Aspect | Wallet Creation | Transfer |
|---|---|---|
| WebAuthn Flow | Registration | Authentication |
| Challenge Source | Random | Transaction hash |
| Passkey | Creates new | Uses existing |
| On-chain Action | Deploy contract | Sign transaction |
Critical Insight: For transfers, the WebAuthn challenge must be derived from the Soroban transaction simulation. This ensures the signature is bound to the specific transaction being authorized.

The challenge is derived from the transaction simulation:
<em>// apps/backend/src/api/transfer-options.ts</em>
<strong>async</strong> handle(payload: { fromWalletAddress: string; toWalletAddress: string; amount: number }) {
<strong>const</strong> { fromWalletAddress, toWalletAddress, amount } = payload;
<strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByWalletAddress(
fromWalletAddress,
<strong>this</strong>.userRepository,
<strong>this</strong>.passkeyRepository
);
<em>// 1. Build transfer transaction args</em>
<strong>const</strong> args: xdr.ScVal[] = [
ScConvert.accountIdToScVal(fromWalletAddress),
ScConvert.accountIdToScVal(toWalletAddress),
ScConvert.stringToScVal(ScConvert.stringToPaddedString(amount.toString())),
];
<em>// 2. Simulate the transaction</em>
<strong>const</strong> { tx, simulationResponse } = <strong>await</strong> <strong>this</strong>.sorobanService.simulateContractOperation({
contractId: <strong>this</strong>.nativeTokenContractId,
method: "transfer",
args,
});
<em>// 3. Generate challenge from TX simulation (CRITICAL!)</em>
<strong>const</strong> challenge = <strong>await</strong> <strong>this</strong>.sorobanService.generateWebAuthnChallenge({
contractId: <strong>this</strong>.nativeTokenContractId,
simulationResponse,
signer: { addressId: user.walletContractAddress! },
});
<em>// 4. Generate authentication options with TX-linked challenge</em>
<strong>const</strong> options = <strong>await</strong> <strong>this</strong>.webauthnAuthenticationHelper.generateOptions({
type: "raw",
user,
customChallenge: challenge,
customMetadata: {
type: "soroban",
tx: tx,
simulationResponse: simulationResponse,
},
});
<strong>return</strong> { options_json: options };
}Code language: HTML, XML (xml)
Key Points:
<em>// apps/web/src/services/transfer/index.ts</em>
<strong>async</strong> handle(input: TransferInput): Promise<TransferOutput> {
<strong>const</strong> { fromWalletAddress, toWalletAddress, amount } = input;
<em>// 1. Get transfer options from server (challenge = TX hash)</em>
<strong>const</strong> { data: transferOptions } = <strong>await</strong> http.get("/api/transfer-options", {
params: {
from_wallet_address: fromWalletAddress,
to_wallet_address: toWalletAddress,
amount,
},
});
<strong>const</strong> optionsJSON = JSON.parse(transferOptions.options_json);
<em>// 2. Authenticate with passkey (signs the TX-derived challenge)</em>
<strong>const</strong> { rawResponse: authenticationResponse } =
<strong>await</strong> <strong>this</strong>.webauthnService.authenticateWithPasskey({ optionsJSON });
<em>// 3. Complete transfer on server</em>
<strong>const</strong> { data: transferResult } = <strong>await</strong> http.post(`/api/transfer`, {
from_wallet_address: fromWalletAddress,
to_wallet_address: toWalletAddress,
amount,
authentication_response_json: JSON.stringify(authenticationResponse),
});
<strong>return</strong> transferResult;
}Code language: HTML, XML (xml)
<em>// apps/backend/src/api/transfer.ts</em>
<strong>async</strong> handle(payload: {
fromWalletAddress: string;
toWalletAddress: string;
amount: number;
authenticationResponseJSON: string;
}) {
<strong>const</strong> { fromWalletAddress, authenticationResponseJSON } = payload;
<strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByWalletAddress(<em>/*...*/</em>);
<em>// 1. Verify authentication challenge</em>
<strong>const</strong> verifyAuth = <strong>await</strong> <strong>this</strong>.webauthnAuthenticationHelper.complete({
type: "raw",
user,
authenticationResponseJSON,
});
<strong>if</strong> (!verifyAuth) <strong>throw</strong> <strong>new</strong> Error("Unable to complete passkey authentication");
<em>// 2. Retrieve TX metadata stored during options generation</em>
<strong>const</strong> { customMetadata } = verifyAuth;
<strong>if</strong> (!customMetadata || customMetadata.type !== "soroban") {
<strong>throw</strong> <strong>new</strong> Error("Unable to find Soroban custom metadata");
}
<em>// 3. Build contract signer with passkey signature</em>
<strong>const</strong> passkeySigner: ContractSigner = {
addressId: user.walletContractAddress!,
methodOptions: {
method: "webauthn",
options: {
clientDataJSON: verifyAuth.clientDataJSON,
authenticatorData: verifyAuth.authenticatorData,
signature: verifyAuth.compactSignature,
},
},
};
<em>// 4. Sign auth entries with passkey signature</em>
<strong>const</strong> tx = <strong>await</strong> <strong>this</strong>.sorobanService.signAuthEntries({
contractId: <strong>this</strong>.nativeTokenContractId,
tx: customMetadata.tx,
simulationResponse: customMetadata.simulationResponse,
signers: [passkeySigner],
});
<em>// 5. Re-simulate, prepare, sign with source account, and submit</em>
<strong>await</strong> <strong>this</strong>.sorobanService.simulateTransaction(tx);
<strong>const</strong> preparedTx = <strong>await</strong> <strong>this</strong>.sorobanService.prepareTransaction(tx);
<strong>const</strong> signedTx = <strong>await</strong> <strong>this</strong>.sorobanService.signTransactionWithSourceAccount(preparedTx);
<strong>const</strong> sentTx = <strong>await</strong> <strong>this</strong>.sorobanService.sendTransaction(signedTx);
<strong>return</strong> { transactionHash: sentTx.txHash };
}Code language: HTML, XML (xml)
If validation succeeds:
The sign-in flow authenticates users using their existing passkeys without any on-chain interaction.
Even though sign-in doesn’t require blockchain transactions, it’s important for:
| Aspect | Sign-In | Transfer |
|---|---|---|
| Challenge | Random server-generated | TX-derived |
| On-chain | No | Yes |
| Purpose | Session auth | TX authorization |
// apps/backend/src/api/sign-in-options.ts
async handle(payload: { email: string }) {
const { email } = payload;
const userWithPasskeys = await getUserWithPasskeysByEmail(/*...*/);
if (userWithPasskeys.passkeys.length === 0) {
throw new Error("User has no passkeys");
}
// Generate standard authentication options (random challenge)
const optionsJSON = await this.webauthnAuthenticationHelper.generateOptions({
type: "standard", // Uses random challenge, not TX-derived
user: userWithPasskeys,
});
return { options_json: optionsJSON };
}
Sign-In Completion (Backend):
// apps/backend/src/api/sign-in.ts
async handle(payload: { email: string; authenticationResponseJSON: string }) {
const { email, authenticationResponseJSON } = payload;
const userWithPasskeys = await getUserWithPasskeysByEmail(/*...*/);
// Verify passkey authentication
const challengeResult = await this.webauthnAuthenticationHelper.complete({
type: "standard",
user: userWithPasskeys,
authenticationResponseJSON,
});
if (!challengeResult) throw new Error("Unable to complete passkey authentication");
// If validation succeeds, user is authenticated
return { walletAddress: userWithPasskeys.walletContractAddress };
}
Sign-In Service (Frontend):
// apps/web/src/services/sign-in/index.ts
async handle(input: SignInInput): Promise<SignInOutput> {
const { email } = input;
// 1. Get authentication options
const { data: authenticationOptions } = await http.get(
`/api/sign-in-options/${email}`
);
const optionsJSON = JSON.parse(authenticationOptions.options_json);
// 2. Authenticate with passkey
const { rawResponse: authenticationResponse } =
await this.webauthnService.authenticateWithPasskey({ optionsJSON });
// 3. Complete sign-in
const { data: signInResult } = await http.post(`/api/sign-in`, {
email,
authentication_response_json: JSON.stringify(authenticationResponse),
});
return signInResult;
}Code language: JavaScript (javascript)
If validation succeeds:
The recovery flow enables users who have lost access to their passkey to regain control of their wallet by registering a new passkey. This is achieved through a recovery account that was set during wallet creation.
When a smart wallet is deployed, it stores two signers:
The recovery account (controlled by the backend) can call the rotate_signer method on the smart wallet contract to replace the old passkey with a new one.
<em>// contracts/smart-wallet/src/lib.rs</em>
<strong>impl</strong> Recovery <strong>for</strong> AccountContract {
<strong>fn</strong> rotate_signer(env: Env, new_signer: BytesN<65>) -> Result<(), RecoveryError> {
<em>// Only the recovery account can call this method</em>
<strong>let</strong> recovery = env
.storage()
.instance()
.get::<_, Address>(&DataKey::Recovery)
.ok_or(RecoveryError::RecoveryNotSet)?;
recovery.require_auth();
<em>// Replace the signer with the new passkey public key</em>
env.storage().instance().set(&DataKey::Signer, &new_signer);
Ok(())
}
}Code language: HTML, XML (xml)
| Aspect | Wallet Creation | Wallet Recovery |
|---|---|---|
| WebAuthn Flow | Registration | Registration |
| User Must Exist | No | Yes |
| On-chain Action | Deploy contract | Rotate signer |
| Signer | User’s passkey | Recovery account |

The server generates WebAuthn registration options (to create a new passkey):
<em>// apps/backend/src/api/recover-wallet-options.ts</em>
<strong>async</strong> handle(payload: { email: string }) {
<strong>const</strong> { email } = payload;
<em>// User must already exist with a wallet</em>
<strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByEmail(
email,
<strong>this</strong>.userRepository,
<strong>this</strong>.passkeyRepository
);
<strong>if</strong> (!user || user.walletContractAddress === <strong>null</strong>) {
<strong>throw</strong> <strong>new</strong> Error("User not found or wallet contract address not set");
}
<em>// Generate registration options (to create a NEW passkey)</em>
<strong>const</strong> optionsJSON = <strong>await</strong> <strong>this</strong>.webauthnRegistrationHelper.generateOptions({
userIdentifier: email,
userPasskeys: user.passkeys, <em>// Exclude existing passkeys</em>
});
<strong>return</strong> { options_json: optionsJSON };
}Code language: HTML, XML (xml)
The frontend triggers the passkey registration flow:
<em>// apps/web/src/services/recover-wallet/index.ts</em>
<strong>async</strong> handle(input: RecoverWalletInput): Promise<RecoverWalletOutput> {
<strong>const</strong> { email } = input;
<em>// 1. Get recovery options from server</em>
<strong>const</strong> { data: recoverOptions } = <strong>await</strong> http.get(
`/api/recover-wallet-options/${email}`
);
<strong>const</strong> optionsJSON = JSON.parse(recoverOptions.options_json);
<em>// 2. Start WebAuthn registration (creates NEW passkey)</em>
<strong>const</strong> { rawResponse: recoverPasskeyResponse } =
<strong>await</strong> <strong>this</strong>.webauthnService.createPasskey({ optionsJSON });
<em>// 3. Complete recovery on server</em>
<strong>const</strong> { data: recoverResult } = <strong>await</strong> http.post(`/api/recover-wallet`, {
email,
registration_response_json: JSON.stringify(recoverPasskeyResponse),
});
<strong>return</strong> recoverResult;
}Code language: HTML, XML (xml)
The server validates the new passkey and uses the recovery account to sign the rotate_signer transaction:
<em>// apps/backend/src/api/recover-wallet.ts</em>
<strong>async</strong> handle(payload: { email: string; registrationResponseJSON: string }) {
<strong>const</strong> { email, registrationResponseJSON } = payload;
<strong>const</strong> user = <strong>await</strong> getUserWithPasskeysByEmail(<em>/*...*/</em>);
<strong>if</strong> (!user || !user.walletContractAddress) {
<strong>throw</strong> <strong>new</strong> Error("User not found or wallet contract address not set");
}
<em>// 1. Validate new passkey registration</em>
<strong>const</strong> challengeResult = <strong>await</strong> <strong>this</strong>.webauthnRegistrationHelper.complete({
userIdentifier: email,
userPasskeys: user.passkeys,
registrationResponseJSON,
});
<strong>if</strong> (!challengeResult) <strong>throw</strong> <strong>new</strong> Error("Unable to complete passkey registration");
<em>// 2. Store the new passkey in the database</em>
<strong>const</strong> newPasskey = challengeResult.newPasskey;
<strong>await</strong> <strong>this</strong>.passkeyRepository.createPasskey({
userId: user.userId,
...newPasskey,
});
<em>// 3. Prepare rotate_signer transaction</em>
<strong>const</strong> args: xdr.ScVal[] = [
ScConvert.hexPublicKeyToScVal(newPasskey.credentialHexPublicKey),
];
<em>// 4. Recovery account signs the transaction (not the user!)</em>
<strong>const</strong> signers: ContractSigner[] = [
{
addressId: <strong>this</strong>.recoveryWalletKeypair.publicKey(),
methodOptions: {
method: "keypair",
options: {
secret: <strong>this</strong>.recoveryWalletKeypair.secret(),
},
},
},
];
<em>// 5. Simulate and execute the transaction</em>
<strong>const</strong> { tx } = <strong>await</strong> <strong>this</strong>.sorobanService.simulateContractOperation({
contractId: user.walletContractAddress, <em>// Call the user's wallet contract</em>
method: "rotate_signer",
args,
signers,
});
<strong>const</strong> preparedTx = <strong>await</strong> <strong>this</strong>.sorobanService.prepareTransaction(tx);
<strong>const</strong> signedTx = <strong>await</strong> <strong>this</strong>.sorobanService.signTransactionWithSourceAccount(preparedTx);
<strong>const</strong> sentTx = <strong>await</strong> <strong>this</strong>.sorobanService.sendTransaction(signedTx);
<strong>return</strong> {
walletAddress: user.walletContractAddress,
transactionHash: sentTx.txHash,
};
}Code language: HTML, XML (xml)
| Aspect | Implication |
|---|---|
| Recovery account is centralized | The backend controls the recovery key. In production, consider multi-sig or time-locked recovery |
| User identity verification | This PoC only requires email. Production systems should add additional verification (KYC, security questions, etc.) |
| Old passkey invalidation | Once rotated, the old passkey cannot sign transactions, preventing malicious use of compromised credentials |
The PoC includes additional features worth exploring:
Transfers testnet XLM from the operations wallet to newly created wallets. This enables users to start using their wallet immediately after creation:
POST /api/fund-wallet
Body: { wallet_address: string }
The operations wallet (OPEX_WALLET_SECRET_KEY) sponsors this funding, allowing users to interact with the blockchain without first acquiring tokens.
Retrieves the native token balance for a wallet:
GET /api/balance?wallet_address=C...
This endpoint queries the Soroban RPC to fetch the current balance of the smart wallet contract.
This guide demonstrated how to build WebAuthn-enabled smart wallets on Soroban, eliminating the need for users to manage private keys. Key takeaways:
The complete proof of concept is available here!
Feel free to:
Roberson works on projects across mobile, backend, frontend, blockchain, and infrastructure, helping deliver scalable and reliable solutions for global clients.