g2c facilitates migration from traditional Stellar accounts ("G-addresses") to Soroban Smart Accounts ("C-addresses") using WebAuthn/passkey authentication. The system consists of three Soroban smart contracts (built on OpenZeppelin's stellar-accounts), a passkey SDK (@g2c/passkey-sdk), and an Astro-based web app deployed to Cloudflare Pages with a wildcard subdomain architecture. All passkey verification happens on-chain — there is no off-chain backend.
- Onboarding: User opens wallet → generates ephemeral G-address → funds it → creates passkey → wallet deploys C-address via Factory → funds migrate from G to C.
- Signing: dApp redirects user to their account subdomain with a hash to sign → user approves with passkey → wallet redirects back with signature components.
A static Astro site deployed to Cloudflare Pages at mysoroban.xyz, using a subdomain-per-account pattern where each Smart Account lives at <contractId>.mysoroban.xyz. A Cloudflare Worker proxies wildcard subdomain requests to the main site. The frontend extracts the contract ID from the hostname.
All Stellar interaction happens client-side via @stellar/stellar-sdk. There are no server-side API routes.
Pages:
| Route | Purpose |
|---|---|
/ |
G2C migration entry point. Generates ephemeral G-address keypair, funds via Friendbot (testnet), computes the predicted C-address via Factory.get_c_address(), and links to the deployment page. |
/new-account/ |
Passkey registration + Smart Account deployment. Calls navigator.credentials.create() with P-256 (alg: -7), RP ID scoped to the subdomain hostname. Builds and submits the Factory.create_account() transaction signed by the funder keypair. |
/account/ |
Account home + signing endpoint. Home mode: register passkeys and sign arbitrary hashes. Signing mode (via ?sign=<hash>&callback=<url>): displays signature request, user approves with passkey, redirects back to callback with authenticatorData, clientDataJSON, signature, and publicKey as query params. |
/dapp/ |
Demo dApp page. Generates a random transaction hash, redirects to the target account's /account/ signing endpoint, and displays the returned signature. |
Subdomain isolation: The WebAuthn RP ID is the full hostname (e.g., CABC123.mysoroban.xyz), so passkeys are cryptographically scoped per-account at the DNS level. A passkey registered for one account cannot sign for another.
TypeScript SDK (packages/passkey-sdk/) providing WebAuthn + Soroban integration utilities. Peer dependency on @stellar/stellar-sdk.
| Module | Exports | Purpose |
|---|---|---|
webauthn.ts |
extractPublicKey, parseAttestationObject, parseRegistration |
Extract 65-byte uncompressed P-256 public key from WebAuthn registration responses. Includes CBOR fallback for mobile/Capacitor. |
signature.ts |
derToCompact |
Convert ASN.1 DER ECDSA signatures to 64-byte compact (r ‖ s) with P-256 low-S normalization (required by Stellar). |
auth.ts |
buildAuthHash, getAuthEntry, parseAssertionResponse, injectPasskeySignature |
Construct Soroban authorization hashes, parse WebAuthn assertion responses, and inject passkey signatures into transaction auth entries. |
deploy.ts |
getContractSalt, computeAccountAddress, lookupExistingAccount, deploySmartAccount |
Compute deterministic C-addresses, check for existing deployments, and execute full deployment flows via the Factory contract. |
Auto-generated TypeScript clients for each contract (via stellar contract bindings typescript). Three sub-packages: factory, smart-account, webauthn-verifier. Expose typed methods and the full OZ smart account type system (context rules, signers, policies, threshold types).
All contracts are #![no_std] and delegate core logic to OpenZeppelin's stellar-accounts library.
| Contract | Source | Description |
|---|---|---|
g2c-factory |
contracts/factory/ |
Deployment orchestrator. create_account(funder, key) deploys a SmartAccount with a WebAuthn signer. get_c_address(funder) pre-computes the deterministic C-address. Uses deployer.with_address(funder, salt=0x00..00) so each funder maps to exactly one C-address. Lazy-deploys a shared WebAuthn verifier via try-invoke pattern (attempts verify() on the expected address; deploys if it fails). Hardcoded WASM hashes for deterministic deployment. |
g2c-smart-account |
contracts/smart-account/ |
Implements OZ CustomAccountInterface + SmartAccount + ExecutionEntryPoint. Constructor takes signers and policies, creates a default ContextRule with the initial passkey signer. __check_auth delegates to do_check_auth from stellar-accounts. execute(target, target_fn, target_args) provides a generic entry point for arbitrary contract calls. All signer/policy mutations require the account's own auth. |
g2c-webauthn-verifier |
contracts/webauthn-verifier/ |
Stateless OZ Verifier for secp256r1/P-256 passkey signatures. KeyData = BytesN<65> (uncompressed public key), SigData = WebAuthnSigData (signature, authenticator_data, client_data). Deploy once, shared across all smart accounts. |
Cross-contract integration tests using synthetic P-256 keypairs (p256::ecdsa::SigningKey::random()). Test helpers construct full WebAuthn assertions without a browser: base64url-encode the challenge, build minimal authenticatorData (37 bytes), construct clientDataJSON, compute the message digest (SHA-256(authData || SHA-256(clientData))), sign with prehash ECDSA, and normalize to low-S. Tests cover verifier correctness, full __check_auth flows, and deployment setup validation.
- User opens the g2c wallet at
mysoroban.xyz. - Wallet generates a random Stellar keypair (
G_temp) and displays the G-address for funding. - User funds
G_temp(Friendbot on testnet; CEX withdrawal, another wallet, or fiat on-ramp on mainnet). - Wallet calls
Factory.get_c_address(G_temp)to compute the deterministic C-address. - Wallet links the user to
<C-address>.mysoroban.xyz/new-account/?key=<G_temp_secret>. - User creates a passkey via
navigator.credentials.create()with RP ID =<C-address>.mysoroban.xyz. - Wallet extracts the 65-byte uncompressed P-256 public key from the registration response.
- Wallet constructs a transaction invoking
Factory.create_account(G_temp, pubkey):- Factory lazy-deploys the shared WebAuthn verifier (if not yet deployed).
- Factory deploys a new SmartAccount with the passkey as the initial
Externalsigner.
- Wallet simulates, assembles, signs with
G_temp, and submits to the Stellar network. - Result: SmartAccount is live at the deterministic C-address, passkey is the owner. User is redirected to
<C-address>.mysoroban.xyz/account/.
The cross-app signing protocol uses URL redirects with query parameters:
- dApp constructs a transaction hash to sign and the callback URL.
- dApp redirects user to
<contractId>.mysoroban.xyz/account/?sign=<hash>&callback=<dapp-url>. - Wallet displays the signature request for user review.
- User approves and signs with their passkey (
navigator.credentials.get()with the hash as challenge). - Wallet redirects back to the callback URL with query params:
authenticatorData,clientDataJSON,signature(compact 64-byte),publicKey. - dApp receives the signature components and can inject them into the transaction's auth entries for submission.
- Transaction invoking the SmartAccount's
execute()is submitted to the network. - Stellar runtime calls
SmartAccount.__check_auth(). - SmartAccount delegates to OZ
do_check_auth, which:- Looks up the signer's context rule.
- Calls
WebAuthnVerifier.verify()with the signature data and public key. - Verifier validates the secp256r1 signature against the authenticatorData + clientDataJSON + challenge.
- Context rules enforce signer scope (target contracts, spending limits, time windows).
- Transaction proceeds if all checks pass.
| Component | Platform | Details |
|---|---|---|
| Web App | Cloudflare Pages | Static Astro build. Deploy via just cloudflare-deploy. |
| Subdomain Proxy | Cloudflare Worker | Route *.mysoroban.xyz/* proxied to the Pages site. Enables subdomain-per-account. |
| Contracts | Stellar Testnet | Factory deployed at CBE3XJK5CLGHPHD46LQSSLHO5R5TIUWBODETEHOLLTMBKK33P3XSJLTZ. WASM hashes hardcoded in factory. |
| Contract Builds | just build-contracts |
stellar contract build --optimize --profile contract producing wasm32 artifacts. |
- Subdomain Passkey Isolation: Each account's passkey is bound to its subdomain RP ID (
<contractId>.mysoroban.xyz), preventing cross-account signature reuse at the WebAuthn protocol level. - On-Chain Verification: All passkey signature verification happens on-chain via the WebAuthn verifier contract. There is no off-chain validation step that could be bypassed.
- G-Key Ephemerality: The
G_tempprivate key is used only for the deployment transaction and should be discarded afterward. On testnet, the secret is passed via URL query parameter (acceptable for development; must change for mainnet). - Passkey Recovery: SmartAccount supports multiple admin signers via context rules. Users should register a backup device after onboarding.
- Replay Protection: SmartAccount nonce tracking (via OZ stellar-accounts) prevents replay. Each WebAuthn assertion challenge is bound to the specific transaction payload.
- Scoped Sessions: Context rules can restrict session signers to specific contracts, functions, spending limits, and time windows — enforced on-chain by the SmartAccount.
graph TB
subgraph "Cloudflare"
Worker["Cloudflare Worker<br/>*.mysoroban.xyz proxy"]
Pages["Cloudflare Pages<br/>Astro Static Site"]
Worker --> Pages
end
subgraph "Browser"
WebAuthn["WebAuthn API<br/>navigator.credentials"]
SDK["@g2c/passkey-sdk"]
Bindings["Contract Bindings<br/>factory / smart-account / verifier"]
StellarSDK["@stellar/stellar-sdk"]
SDK --> StellarSDK
Bindings --> StellarSDK
end
subgraph "Stellar Network"
Factory["g2c-factory"]
SmartAccount["g2c-smart-account"]
Verifier["g2c-webauthn-verifier"]
Factory -- "deploys" --> SmartAccount
Factory -- "lazy-deploys" --> Verifier
SmartAccount -- "verify()" --> Verifier
end
Pages --> SDK
Pages --> Bindings
Pages --> WebAuthn
StellarSDK -- "RPC" --> Factory
StellarSDK -- "RPC" --> SmartAccount
sequenceDiagram
actor User
participant Wallet as mysoroban.xyz
participant WebAuthn as WebAuthn API
participant Stellar as Stellar Network
participant Factory as g2c-factory
participant SA as SmartAccount
participant WV as WebAuthn Verifier
User->>Wallet: Open wallet
Wallet->>Wallet: Generate ephemeral keypair (G_temp)
Wallet-->>User: Display G-address for funding
User->>Stellar: Fund G_temp (Friendbot / CEX / etc.)
Wallet->>Factory: get_c_address(G_temp)
Factory-->>Wallet: Deterministic C-address
Wallet-->>User: Redirect to <C-addr>.mysoroban.xyz/new-account/
User->>WebAuthn: navigator.credentials.create()<br/>RP ID = <C-addr>.mysoroban.xyz
WebAuthn-->>Wallet: Registration response (P-256 public key)
Wallet->>Wallet: Extract 65-byte uncompressed pubkey
Wallet->>Wallet: Build TX: Factory.create_account(G_temp, pubkey)
Wallet->>Stellar: Simulate + Assemble + Sign with G_temp + Submit
Stellar->>Factory: create_account(G_temp, pubkey)
Factory->>WV: Try verify() — deploy if absent
Factory->>SA: Deploy with passkey as External signer
Stellar-->>Wallet: TX confirmed
Wallet-->>User: Redirect to <C-addr>.mysoroban.xyz/account/
sequenceDiagram
actor User
participant dApp as dApp (any origin)
participant Wallet as <C-addr>.mysoroban.xyz/account/
participant WebAuthn as WebAuthn API
dApp->>dApp: Construct transaction hash
dApp->>Wallet: Redirect: ?sign=<hash>&callback=<dapp-url>
Wallet-->>User: Display signature request
User->>WebAuthn: navigator.credentials.get()<br/>challenge = hash
WebAuthn-->>Wallet: Assertion (authenticatorData,<br/>clientDataJSON, signature)
Wallet->>Wallet: Convert DER signature to compact (r‖s)
Wallet->>dApp: Redirect to callback with query params:<br/>authenticatorData, clientDataJSON,<br/>signature, publicKey
dApp->>dApp: Inject signature into TX auth entries
dApp->>Stellar: Submit signed transaction
sequenceDiagram
participant Submitter as TX Submitter
participant Stellar as Stellar Runtime
participant SA as SmartAccount
participant OZ as OZ do_check_auth
participant WV as WebAuthn Verifier
Submitter->>Stellar: Submit TX invoking SmartAccount.execute()
Stellar->>SA: __check_auth(signature_payload, signatures, auth_contexts)
SA->>OZ: do_check_auth(...)
OZ->>OZ: Look up signer's context rule
OZ->>WV: verify(payload, pubkey, WebAuthnSigData)
WV->>WV: Validate secp256r1 signature<br/>against authData + clientData + challenge
WV-->>OZ: Valid / Invalid
OZ->>OZ: Enforce context rules<br/>(contracts, limits, time windows)
OZ-->>SA: Auth result
SA-->>Stellar: Auth result
Stellar->>Stellar: Execute transaction ops
graph LR
subgraph "One-time Setup"
Install["stellar contract install<br/>(WASM → hashes)"]
Deploy["Deploy g2c-factory<br/>(hardcoded WASM hashes)"]
Install --> Deploy
end
subgraph "Per-User (via Factory)"
Funder["G_temp (funder)"]
FactoryC["g2c-factory"]
CAddr["SmartAccount<br/>at deterministic C-address"]
VerifierC["WebAuthn Verifier<br/>(shared singleton)"]
Funder -- "create_account(funder, pubkey)" --> FactoryC
FactoryC -- "deploy_v2<br/>salt=0x00..00" --> CAddr
FactoryC -- "lazy-deploy<br/>(try-invoke pattern)" --> VerifierC
end
Deploy --> FactoryC