Account Abstraction (AA) Protocol

— ERC-4337 ACCOUNT ABSTRACTION —

A deep-dive into the WebXcom Account Abstraction architecture — how user accounts map to AA wallets, how slot ownership works, gas sponsorship via VerifyingPaymaster, and private key rotation. WebXcom 계정추상화(AA) 아키텍처 심층 가이드 — 사용자 계정과 AA 지갑의 매핑, 슬롯 소유권, VerifyingPaymaster를 통한 가스비 대납, 프라이빗키 교체 시스템을 다룹니다.

📐 Topics Covered

This guide covers the 6 core pillars of the WebXcom AA system. 이 가이드는 WebXcom AA 시스템의 6가지 핵심 주제를 다룹니다.

1
Account Architecture
Google/Email → EOA → SimpleAccount hierarchy Google/Email → EOA → SimpleAccount 계층 구조
2
Slot Ownership
AA wallet & content_address ownership model AA 지갑 & content_address 소유권 모델
3
Slot Transfer
On-chain ownership transfer via UserOp UserOp을 통한 온체인 소유권 이전
4
Gas Sponsorship
VerifyingPaymaster gas-free transactions VerifyingPaymaster 가스비 대납
5
PIN Security
User-facing security layer for key operations 키 조작을 위한 사용자 보안 레이어
6
Key Rotation
Private key replacement without address change 주소 변경 없이 프라이빗키 교체

01 Account Architecture Overview 계정 구조 개요

When a user signs up via Google OAuth or email, the WebXcom platform automatically provisions the full blockchain identity stack — no seed phrases, no wallet extensions, no Web3 knowledge required from the user. 사용자가 Google OAuth 또는 이메일로 가입하면, WebXcom 플랫폼은 전체 블록체인 신원 스택을 자동으로 프로비저닝합니다 — 시드 구문, 지갑 확장 프로그램, Web3 지식이 사용자에게 일절 필요하지 않습니다.

ℹ️ Hierarchy Summary ℹ️ 계층 구조 요약
Google/Email Account → identified by user_uuid in the DB → the platform generates a owner_eoa (an Externally Owned Account whose private key is encrypted and stored server-side) → which in turn controls a SimpleAccount (the ERC-4337 AA wallet, deployed via CREATE2 for a deterministic counterfactual address). Google/Email 계정 → DB에서 user_uuid로 식별 → 플랫폼이 owner_eoa (프라이빗키가 암호화되어 서버에 저장되는 EOA)를 생성 → 이 EOA가 SimpleAccount (CREATE2를 통해 결정론적 카운터팩추얼 주소로 배포되는 ERC-4337 AA 지갑)를 제어합니다.

The complete account hierarchy: 전체 계정 계층 구조:

Google / Email Account
    └── user_uuid (DB Primary Key)
         └── owner_eoa  (Platform-generated EOA, private key encrypted in DB)
              └── SimpleAccount  (AA Wallet, Counterfactual Address via CREATE2)
                   └── content_address  (Slot/Deck address)

Counterfactual Address (CREATE2) — The AA wallet address is computed deterministically from the factory address, the owner EOA address, and a salt value. This means the address is known before the contract is actually deployed on-chain. The first transaction that touches this wallet will trigger deployment automatically via the initCode field of the UserOperation. 카운터팩추얼 주소 (CREATE2) — AA 지갑 주소는 팩토리 주소, owner EOA 주소, salt 값으로 결정론적으로 계산됩니다. 즉 컨트랙트가 온체인에 실제 배포되기 전에 주소를 미리 알 수 있습니다. 이 지갑에 대한 첫 번째 트랜잭션이 UserOperation의 initCode 필드를 통해 자동으로 배포를 트리거합니다.

⚠️ Key Custody ⚠️ 키 관리 주체
The user never directly manages the owner_eoa private key. The platform holds it encrypted in the database. The AA wallet (SimpleAccount) is the user's on-chain identity — all transactions originate from this contract address. 사용자는 owner_eoa 프라이빗키를 직접 관리하지 않습니다. 플랫폼이 암호화하여 데이터베이스에 보관합니다. AA 지갑(SimpleAccount)이 사용자의 온체인 신원이며 — 모든 트랜잭션은 이 컨트랙트 주소에서 시작됩니다.

Account Provisioning Flow 계정 프로비저닝 플로우

sequenceDiagram participant U as User participant P as Platform (A서버) participant B as Blockchain Service (B서버) participant C as Chain U->>P: Sign up (Google / Email) P->>P: Create user_uuid in DB P->>B: Generate owner EOA B->>B: Create mnemonic → derive EOA B->>B: Encrypt private key B-->>P: { address, encrypted_key } P->>P: Compute AA address (CREATE2: factory + owner + W_SALT) P->>P: Store owner_eoa + AA address in DB P-->>U: Account ready (AA address assigned) Note over C: SimpleAccount not yet deployed on-chain
(Counterfactual — deployed on first tx)

02 AA Wallet & Slot Address / Ownership AA 지갑과 Slot(Deck) 주소 및 소유권

In the WebXcom system, the AA wallet address is the user's canonical on-chain identity. When the SimpleAccount executes a call on any contract, msg.sender is the SimpleAccount's address — not the owner EOA. WebXcom 시스템에서 AA 지갑 주소는 사용자의 정규 온체인 신원입니다. SimpleAccount가 컨트랙트를 호출하면, msg.sender는 owner EOA가 아닌 SimpleAccount의 주소입니다.

Each slot (deck) also has its own dedicated AA wallet called content_address. This is computed with a different salt (C_SALT) using the same owner EOA, creating a separate SimpleAccount for content-level operations. 각 슬롯(덱)도 content_address라는 전용 AA 지갑을 가집니다. 동일한 owner EOA에 다른 salt(C_SALT)를 사용하여 계산되며, 콘텐츠 수준의 작업을 위한 별도의 SimpleAccount를 생성합니다.

Entity Address Type Salt Description
User SimpleAccount W_SALT Main AA wallet — the user's primary on-chain identity and msg.sender 메인 AA 지갑 — 사용자의 주요 온체인 신원이자 msg.sender
Slot / Deck SimpleAccount C_SALT Content AA wallet — dedicated address for the slot/deck content 콘텐츠 AA 지갑 — 슬롯/덱 콘텐츠 전용 주소

Ownership assignment — When a slot is purchased or minted, the dex contract's buy_slot(aaAddress, slot, ...) function registers the user's AA wallet address as the slot owner. On-chain ownership is determined solely by what is recorded in the dex contract. 소유권 할당 — 슬롯이 구매 또는 민팅되면, dex 컨트랙트의 buy_slot(aaAddress, slot, ...) 함수가 사용자의 AA 지갑 주소를 슬롯 소유자로 등록합니다. 온체인 소유권은 dex 컨트랙트에 기록된 내용에 의해서만 결정됩니다.

// On-chain ownership check (Solidity)
// dex contract stores: mapping(uint256 => address) public slotOwner;

function buy_slot(address aaAddress, uint256 slot, ...) external {
    // ... payment logic ...
    slotOwner[slot] = aaAddress;  // User's SimpleAccount(W_SALT)
}

// When querying ownership:
// slotOwner[slot] == user's AA wallet address (SimpleAccount)
// Address computation (off-chain)
// Both addresses are derived from the SAME owner EOA but with different salts

const userAAAddress    = computeAddress(factory, ownerEoa, W_SALT);  // Main wallet
const contentAddress   = computeAddress(factory, ownerEoa, C_SALT);  // Slot/Deck wallet

// CREATE2: address = keccak256(0xff ++ factory ++ salt ++ keccak256(initCode))[12:]
ℹ️ Why Two Addresses? ℹ️ 왜 주소가 두 개인가?
The user's main AA wallet (W_SALT) handles identity and ownership. The content address (C_SALT) provides a dedicated namespace for slot/deck-level operations, keeping concerns separated on-chain. 사용자의 메인 AA 지갑(W_SALT)은 신원과 소유권을 처리합니다. 콘텐츠 주소(C_SALT)는 슬롯/덱 수준의 작업을 위한 전용 네임스페이스를 제공하여, 온체인에서 관심사를 분리합니다.

03 Slot Transfer Slot 트랜스퍼

Slot ownership transfer is executed as an AA UserOperation. The platform handles the entire flow — the user simply initiates the request, and the platform signs and submits the on-chain transaction on their behalf. 슬롯 소유권 이전은 AA UserOperation으로 실행됩니다. 플랫폼이 전체 플로우를 처리하며 — 사용자는 요청만 시작하면, 플랫폼이 대신 서명하고 온체인 트랜잭션을 제출합니다.

POST /idex-platform/exchange_token_owner
ParameterTypeRequiredDescription
state string Required Session state token for request validation 요청 검증을 위한 세션 상태 토큰
cu_id string Required Content unit identifier 콘텐츠 유닛 식별자
from_uuid string Required Current owner's user_uuid 현재 소유자의 user_uuid
to_uuid string Required New owner's user_uuid 새 소유자의 user_uuid
meta_idx number Required Metadata index of the slot 슬롯의 메타데이터 인덱스

Server-to-server flow — The request travels from A서버 (OAUTH/NestJS) via gRPC to B서버 (Oauth-blockchain), which constructs and submits the AA UserOperation. 서버 간 플로우 — 요청은 A서버(OAUTH/NestJS)에서 gRPC를 통해 B서버(Oauth-blockchain)로 전달되며, B서버가 AA UserOperation을 구성하고 제출합니다.

// Request body
POST /idex-platform/exchange_token_owner
{
  "state": "session-state-token",
  "cu_id": "content-unit-id",
  "from_uuid": "sender-user-uuid",
  "to_uuid": "receiver-user-uuid",
  "meta_idx": 42
}

The transfer flow end-to-end: 엔드투엔드 트랜스퍼 플로우:

User requests transfer (from_uuid → to_uuid)
    ↓
A서버: /idex-platform/exchange_token_owner
    ↓ gRPC
B서버: dapp_exchange_own_initPermissionSelf
    ↓
AA UserOperation: SimpleAccount.execute(dex.init_self(aaAddress, slot, newOwnerAAAddress))
    ↓ EntryPoint.handleOps
On-chain: Slot ownership changed

Transfer Sequence 트랜스퍼 시퀀스

sequenceDiagram participant U as User participant A as A서버 (NestJS) participant B as B서버 (Blockchain) participant EP as EntryPoint participant DEX as Dex Contract U->>A: POST /idex-platform/exchange_token_owner A->>B: gRPC: dapp_exchange_own_initPermissionSelf B->>B: Build UserOp (callData = dex.init_self) B->>B: Sign with from_user's owner_eoa B->>B: Sign paymaster data (VerifyingPaymaster) B->>EP: submitUserOperation([userOp]) EP->>EP: Validate paymaster signature EP->>EP: Validate owner signature EP->>DEX: SimpleAccount.execute → dex.init_self(aaAddr, slot, newOwner) DEX->>DEX: slotOwner[slot] = newOwnerAAAddress EP-->>B: Transaction receipt B-->>A: Success A-->>U: Transfer complete
ℹ️ Gas-free for Users ℹ️ 사용자에게 가스비 무료
The transfer is submitted via the VerifyingPaymaster, so the transferring user pays zero gas. The platform covers all gas costs. See Section 4 for details. 트랜스퍼는 VerifyingPaymaster를 통해 제출되므로, 이전하는 사용자는 가스비를 전혀 부담하지 않습니다. 플랫폼이 모든 가스 비용을 부담합니다. 자세한 내용은 섹션 4를 참조하세요.

04 Gas Sponsorship — VerifyingPaymaster 가스비 대납 — VerifyingPaymaster

Users on the WebXcom platform never pay gas fees. Every on-chain operation — slot purchases, transfers, ownership changes — is sponsored by the platform's VerifyingPaymaster. This is a core design decision that enables a Web2-like user experience on Web3 infrastructure. WebXcom 플랫폼의 사용자는 가스비를 절대 부담하지 않습니다. 슬롯 구매, 트랜스퍼, 소유권 변경 등 모든 온체인 작업은 플랫폼의 VerifyingPaymaster가 대납합니다. 이는 Web3 인프라 위에서 Web2와 같은 사용자 경험을 제공하기 위한 핵심 설계 결정입니다.

The complete UserOperation lifecycle: UserOperation의 전체 라이프사이클:

executeViaAA()
  1. Build UserOperation
     ├── sender       = aaAddress (SimpleAccount)
     ├── initCode     = factory.createAccount(owner, salt)  // only if first tx
     ├── callData     = SimpleAccount.execute(target, value, data)
     ├── callGasLimit / verificationGasLimit / preVerificationGas
     └── paymasterAndData = (empty, filled in step 2)

  2. signPaymasterData()
     ├── Platform signs: hash(userOp, validUntil, validAfter)
     ├── validUntil   = now + 5 minutes
     ├── validAfter   = now
     └── paymasterAndData = paymaster + validUntil + validAfter + signature

  3. signUserOperation()
     ├── owner_eoa signs the full UserOp hash
     └── signature = owner_eoa.sign(entryPoint.getUserOpHash(userOp))

  4. submitUserOperation()
     └── EntryPoint.handleOps([userOp], beneficiary)
              ↓
         EntryPoint validates Paymaster signature  ✓
         EntryPoint validates owner signature       ✓
         SimpleAccount.execute() runs               ✓
         Gas deducted from Paymaster's EntryPoint deposit

UserOperation Flow UserOperation 플로우

sequenceDiagram participant S as Platform (B서버) participant SA as SimpleAccount participant PM as VerifyingPaymaster participant EP as EntryPoint participant T as Target Contract S->>S: 1. Build UserOperation S->>PM: 2. Request paymaster signature PM->>PM: Sign(validUntil=now+5min) PM-->>S: paymasterAndData S->>S: 3. Sign UserOp with owner_eoa S->>EP: 4. handleOps([userOp], beneficiary) EP->>PM: validatePaymasterUserOp() PM->>PM: Verify signature + check validUntil PM-->>EP: validation success + context EP->>SA: validateUserOp() SA->>SA: Verify owner_eoa signature SA-->>EP: validation success EP->>SA: execute(target, value, data) SA->>T: call(data) T-->>SA: result EP->>PM: postOp() — deduct gas from deposit
ℹ️ initCode — First Transaction Deployment ℹ️ initCode — 첫 트랜잭션 배포
On the first UserOperation for a new AA wallet, initCode contains the factory call that deploys the SimpleAccount contract. The EntryPoint executes this before validation. All subsequent transactions use initCode = '0x' (empty) since the contract already exists. 첫 번째 UserOperation에서 initCode는 SimpleAccount 컨트랙트를 배포하는 팩토리 호출을 포함합니다. EntryPoint가 검증 전에 이를 실행합니다. 이후 모든 트랜잭션은 컨트랙트가 이미 존재하므로 initCode = '0x'(빈 값)를 사용합니다.
// initCode construction (first tx only)
const initCode = ethers.utils.solidityPack(
  ['address', 'bytes'],
  [
    FACTORY_ADDRESS,
    factory.interface.encodeFunctionData('createAccount', [ownerEoa, salt])
  ]
);

// Subsequent transactions
const initCode = '0x';  // Contract already deployed
⚠️ validUntil — Replay Protection ⚠️ validUntil — 재생 공격 방지
The paymaster signature includes a validUntil timestamp set to now + 5 minutes. After this window, the signature is rejected by the EntryPoint. This prevents captured UserOperations from being replayed later. 페이마스터 서명에는 현재 시간 + 5분으로 설정된 validUntil 타임스탬프가 포함됩니다. 이 시간이 지나면 EntryPoint에서 서명이 거부됩니다. 이를 통해 캡처된 UserOperation의 재생 공격을 방지합니다.
UserOp FieldFirst TransactionSubsequent Transactions
sender AA address (counterfactual, not yet deployed) AA 주소 (카운터팩추얼, 아직 미배포) AA address (deployed contract) AA 주소 (배포된 컨트랙트)
initCode factory + createAccount(owner, salt) '0x'
nonce 0 Auto-incremented by EntryPoint EntryPoint에 의해 자동 증가
paymasterAndData paymaster address + validUntil + validAfter + paymaster signature

05 PIN & XOR 3-of-3 Key Architecture PIN과 XOR 3-of-3 키 분산 보관

The PIN is a user-facing security mechanism that bridges the gap between Web2 familiarity and Web3 key management. It is not the private key of the AA wallet. Rather, the PIN is one of three cryptographic shares required to reconstruct the owner EOA private key — using an XOR 3-of-3 threshold scheme. No single party ever holds the complete private key. PIN은 Web2의 친숙함과 Web3 키 관리 사이의 간극을 메우는 사용자 대면 보안 메커니즘입니다. AA 지갑의 프라이빗키가 아닙니다. PIN은 XOR 3-of-3 임계값 방식으로 owner EOA 프라이빗키를 복원하기 위해 필요한 세 개의 암호학적 조각 중 하나입니다. 어떤 단일 주체도 완전한 프라이빗키를 단독으로 보유하지 않습니다.

5-1. XOR 3-of-3 Key Splitting Overview 5-1. XOR 3-of-3 키 분할 개요

When a PIN wallet is created, the owner EOA private key is split into three shares via bitwise XOR. All three shares must be combined to recover the original key: PIN 지갑이 생성될 때, owner EOA 프라이빗키는 비트 XOR을 통해 세 개의 조각으로 분할됩니다. 원래 키를 복원하려면 세 조각 모두 필요합니다:

fullPrivateKey  =  share_pin  ⊕  share_server  ⊕  share_user

share_pin    :  PBKDF2-SHA256(pin_hash, 'webxcom-pin-salt', 100_000 iter, 32 bytes)
                — derived on-demand from user's PIN; never stored anywhere
share_server :  crypto.randomBytes(32)
                — stored in B서버 DB (Key table, b_key column)
share_user   :  fullPrivateKey ⊕ share_pin ⊕ share_server
                — returned to client; stored in Rails encrypted session cookie
Share Holder 보관 주체 Storage 저장 위치 How recovered 복원 방법
share_pin Derived from user's PIN 사용자 PIN에서 파생 Never stored — re-derived on each use 저장하지 않음 — 매번 재파생 User enters PIN → PBKDF2 derivation 사용자 PIN 입력 → PBKDF2 파생
share_server B서버 (Blockchain Service) B서버 (블록체인 서비스) B서버 DB — Key.b_key B서버 DB — Key.b_key Loaded from DB on every transaction 트랜잭션마다 DB에서 로드
share_user Browser (webxcom client) 브라우저 (webxcom 클라이언트) Rails encrypted session cookie (session[:key_share_user]) Rails 암호화 세션 쿠키 (session[:key_share_user]) Sent to A서버 with every PIN-gated request PIN 인증 요청마다 A서버로 전송
⚠️ No Single Point of Compromise ⚠️ 단일 실패 지점 없음
An attacker who gains access to the B서버 DB obtains only share_server — useless without share_user (browser session) and the user's PIN. Similarly, a stolen session cookie yields only share_user — the other two shares remain unknown. The full private key exists in plaintext only inside the B서버 process memory during transaction signing, and is securely wiped immediately after use (Buffer.fill(0)). B서버 DB에 접근한 공격자는 share_server만 얻습니다 — share_user(브라우저 세션)와 사용자 PIN 없이는 무용지물입니다. 마찬가지로 탈취된 세션 쿠키는 share_user만 노출합니다 — 나머지 두 조각은 알 수 없습니다. 완전한 프라이빗키는 트랜잭션 서명 시 B서버 프로세스 메모리 내에서만 잠시 존재하며, 사용 직후 안전하게 초기화됩니다 (Buffer.fill(0)).

5-2. PIN Registration Flow 5-2. PIN 등록 플로우

PIN registration happens during account signup. The platform creates the AA wallet and XOR-splits the private key in a single atomic operation. PIN 등록은 계정 가입 시 발생합니다. 플랫폼은 단일 원자적 연산으로 AA 지갑을 생성하고 프라이빗키를 XOR 분할합니다.

// webxcom → A서버
POST /v1/pin/register
{
  "user_uuid": 12345,
  "pin_hash":  "SHA-256(pin + pin_salt)",   // plaintext PIN never transmitted
  "pin_salt":  "<64-char hex>"
}

// A서버 → B서버 (gRPC)
CreateWalletWithPin({ account_idx, pin_hash })

// B서버 internal:
//   1. Ethers.Wallet.createRandom()  → mnemonic, privateKey, ownerEoaAddress
//   2. AAService.computeAAAddress()  → aaAddress (counterfactual, CREATE2)
//   3. splitKeyXor3(privateKey, pin_hash):
//        share_pin    = PBKDF2(pin_hash, 'webxcom-pin-salt', 100000, 32, sha256)
//        share_server = crypto.randomBytes(32)
//        share_user   = privateKey ⊕ share_pin ⊕ share_server
//   4. DB transaction (atomic):
//        Wallet.create({ account_idx, address: aaAddress, owner_eoa, wallet_type: 'AA' })
//        Key.create({ a_key: UUID(account_idx, U_SALT),       b_key: share_server })
//        Key.create({ a_key: UUID(account_idx, U_SALT)+'_MN', b_key: AES256(mnemonic) })
//   5. privateKey buffer wiped (Buffer.fill(0))

// A서버 response → webxcom
{
  "success": true,
  "data": [{
    "address":       "0xAA...",          // SimpleAccount address
    "mnemonic":      "<AES-256-CTR encrypted>",
    "key_share_user": "0x..."            // share_user — stored in Rails session
  }]
}

PIN Registration Sequence PIN 등록 시퀀스

sequenceDiagram participant U as User (Browser) participant W as webxcom (Rails) participant A as A서버 (NestJS) participant B as B서버 (Blockchain) participant DB as B서버 DB U->>W: Enter PIN (6 digits) W->>W: pin_salt = SecureRandom.hex(32)
pin_hash = SHA256(pin + pin_salt) W->>A: POST /v1/pin/register { user_uuid, pin_hash, pin_salt } A->>B: gRPC CreateWalletWithPin({ account_idx, pin_hash }) B->>B: Generate mnemonic + owner EOA
Compute AA address (CREATE2) B->>B: splitKeyXor3(privateKey, pin_hash)
→ share_server, share_user B->>DB: Key[UUID] = share_server
Key[UUID_MN] = AES256(mnemonic)
Wallet = { aaAddress, owner_eoa } B->>B: privateKey buffer wiped B-->>A: { address, mnemonic_enc, key_share_user } A-->>W: { address, mnemonic, key_share_user } W->>W: session[:key_share_user] = key_share_user
session[:pin_hash] = pin_hash
session[:pin_salt] = pin_salt W-->>U: Show mnemonic (one-time display)

5-3. Transaction Signing Flow (PIN Verification) 5-3. 트랜잭션 서명 플로우 (PIN 검증)

Every sensitive operation (transfer, ownership change, etc.) requires the user to enter their PIN. The three shares are combined in-memory inside B서버 to reconstruct the private key for signing, then immediately wiped. 모든 민감한 작업(이체, 소유권 변경 등)은 사용자 PIN 입력이 필요합니다. 세 조각은 B서버 내부 메모리에서 합산되어 서명용 프라이빗키를 복원하며, 즉시 초기화됩니다.

// webxcom → A서버
POST /v1/pin/verify
{
  "user_uuid":     12345,
  "pin_hash":      "SHA-256(entered_pin + session[:pin_salt])",
  "pin_salt":      "session[:pin_salt]",
  "key_share_user": "session[:key_share_user]"
}

// A서버 → B서버 (gRPC)
ExecutePinTransaction({ account_idx, pin_hash, key_share_user, tx_data })

// B서버 internal:
//   1. share_server = Key.findOne({ a_key: UUID(account_idx, U_SALT) }).b_key
//   2. restoreKeyXor3(key_share_user, pin_hash, share_server):
//        share_pin  = PBKDF2(pin_hash, 'webxcom-pin-salt', 100000, 32, sha256)
//        fullKey    = share_user ⊕ share_pin ⊕ share_server
//   3. Sign UserOperation with fullKey
//   4. fullKey buffer wiped (Buffer.fill(0))
//   5. Submit via EntryPoint

5-4. PIN Reset via Mnemonic 5-4. 니모닉으로 PIN 재설정

If the user remembers their mnemonic (12-word recovery phrase) but forgets their PIN, they can reset the PIN without changing the owner EOA. The existing owner EOA private key is re-split with the new PIN hash. 사용자가 니모닉(12단어 복구 구문)을 기억하지만 PIN을 잊어버린 경우, owner EOA를 변경하지 않고 PIN을 재설정할 수 있습니다. 기존 owner EOA 프라이빗키가 새 PIN 해시로 재분할됩니다.

// POST /v1/pin/reset
{
  "user_uuid":    12345,
  "mnemonic":     "word1 word2 ... word12",  // user's recovery phrase
  "new_pin_hash": "SHA-256(new_pin + new_salt)",
  "new_pin_salt": "<64-char hex>"
}

// B서버 internal (verifyMnemonic):
//   1. Decrypt stored mnemonic (Key[UUID_MN]) → compare with user input
//   2. Derive privateKey from mnemonic (Ethers.fromMnemonic)
//   3. splitKeyXor3(privateKey, new_pin_hash) → new share_server, new share_user
//   4. Key[UUID].b_key ← new share_server
//   5. Return new key_share_user (owner EOA unchanged)

5-5. Why a PIN instead of seed phrases? 5-5. 왜 시드 구문 대신 PIN인가?

Aspect Traditional Web3 전통적 Web3 WebXcom (XOR 3-of-3 PIN) WebXcom (XOR 3-of-3 PIN)
Daily Auth 일상 인증 Wallet extension + signature prompt 지갑 확장 프로그램 + 서명 프롬프트 6-digit PIN (familiar Web2 pattern) 6자리 PIN (친숙한 Web2 패턴)
Key Storage 키 저장 Single encrypted key in custody 단일 암호화 키 보관 XOR 3-of-3: share split across browser session, B서버 DB, and user PIN XOR 3-of-3: 조각이 브라우저 세션, B서버 DB, 사용자 PIN으로 분산
Key Backup 키 백업 User must store 12-24 word seed phrase 사용자가 12-24 단어 시드 구문 보관 필요 Mnemonic stored AES-256-CTR encrypted in B서버 DB (for PIN reset only) 니모닉은 B서버 DB에 AES-256-CTR 암호화 저장 (PIN 재설정 전용)
PIN/Key Loss PIN/키 분실 Permanent loss of funds 자금의 영구적 손실 PIN reset via mnemonic; full key rotation via email verification (Section 6) 니모닉으로 PIN 재설정; 이메일 인증으로 전체 키 교체 (섹션 6)
Custodial Risk 수탁 위험 Custodian holds full key 수탁자가 전체 키 보유 No single party holds full key — 3-of-3 required 단일 주체가 전체 키 미보유 — 3-of-3 필요
User Knowledge 사용자 지식 Must understand blockchain, gas, wallets 블록체인, 가스, 지갑 이해 필요 Zero blockchain knowledge needed 블록체인 지식 불필요
ℹ️ PIN Recovery Options ℹ️ PIN 복구 방법
Option A — Mnemonic Reset (POST /v1/pin/reset): User provides their 12-word mnemonic. B서버 validates it, re-splits the same private key with the new PIN. Owner EOA is unchanged; AA wallet address is unchanged.

Option B — Email Recovery (POST /v1/pin/recover): User verifies identity via email. B서버 generates a new owner EOA, calls SimpleAccount.changeOwner(newOwnerEoa) on-chain, and re-splits the new private key. AA wallet address remains unchanged. See Section 6 for details.
옵션 A — 니모닉 재설정 (POST /v1/pin/reset): 사용자가 12단어 니모닉을 제공합니다. B서버가 검증 후 동일한 프라이빗키를 새 PIN으로 재분할합니다. Owner EOA 불변, AA 지갑 주소 불변.

옵션 B — 이메일 복구 (POST /v1/pin/recover): 사용자가 이메일로 신원 확인. B서버가 owner EOA를 생성하고 SimpleAccount.changeOwner(newOwnerEoa)를 온체인 호출 후, 새 프라이빗키를 재분할합니다. AA 지갑 주소 불변. 자세한 내용은 섹션 6 참조.

06 Private Key Rotation — Email Recovery Flow 프라이빗키 교체 — 이메일 복구 플로우

One of the most powerful properties of Account Abstraction is the ability to rotate the signing key without changing the wallet address. Since the AA wallet is a smart contract (SimpleAccount), its owner field can be updated to point to a new EOA — while the contract address (and thus the on-chain identity) remains permanent. 계정추상화의 가장 강력한 속성 중 하나는 지갑 주소를 변경하지 않고 서명 키를 교체할 수 있다는 것입니다. AA 지갑은 스마트 컨트랙트(SimpleAccount)이므로, owner 필드를 새 EOA를 가리키도록 업데이트할 수 있습니다 — 컨트랙트 주소(즉 온체인 신원)는 영구적으로 유지됩니다.

Key rotation is triggered when the user can no longer recover their PIN via mnemonic (lost both PIN and mnemonic, or suspects key compromise). The user verifies identity via email, and the platform generates a completely new owner EOA, rotates ownership on-chain, and XOR-re-splits the new key with the new PIN. 키 교체는 사용자가 니모닉으로도 PIN을 복구할 수 없는 경우(PIN과 니모닉 모두 분실, 또는 키 유출 의심)에 트리거됩니다. 사용자가 이메일로 신원 확인을 완료하면, 플랫폼은 완전히 새로운 owner EOA를 생성하고, 온체인 소유권을 교체하며, 새 PIN으로 새 키를 XOR 재분할합니다.

POST /v1/pin/recover

This calls the B서버 gRPC service: 이는 B서버 gRPC 서비스를 호출합니다:

// webxcom → A서버 (after email verification)
POST /v1/pin/recover
{
  "user_uuid":    12345,
  "pin_hash":     "SHA-256(new_pin + new_salt)",
  "pin_salt":     "<64-char hex>"
}

// A서버 → B서버 (gRPC)
RecoverWalletKeyWithPin({ account_idx, pin_hash })

Step-by-step breakdown inside B서버 (recover_wallet_key_with_pin): B서버 내부 단계별 상세 설명 (recover_wallet_key_with_pin):

StepActorAction
1 B서버 Load current state: retrieve share_server from Key[UUID] and decrypt Key[UUID_MN] to get stored mnemonic 현재 상태 로드: Key[UUID]에서 share_server 조회, Key[UUID_MN] 복호화하여 니모닉 확인
2 B서버 Validate current owner: derive old owner EOA from stored mnemonic, verify it matches Wallet.owner_eoa; if on-chain check needed, verify SimpleAccount.owner 현재 소유자 검증: 저장된 니모닉에서 기존 owner EOA 파생, Wallet.owner_eoa와 일치 확인; 온체인 검증이 필요하면 SimpleAccount.owner 확인
3 B서버 Generate new owner EOA: Ethers.Wallet.createRandom() → new mnemonic, new private key, new EOA address 새 owner EOA 생성: Ethers.Wallet.createRandom() → 새 니모닉, 새 프라이빗키, 새 EOA 주소
4 B서버 Build UserOperation: callData = SimpleAccount.changeOwner(newOwnerEoa); old owner EOA signs this UserOp (still the current on-chain signer) UserOperation 구성: callData = SimpleAccount.changeOwner(newOwnerEoa); 기존 owner EOA가 서명 (여전히 현재 온체인 서명자)
5 EntryPoint Validates old owner signature → executes SimpleAccount.owner = newOwnerEoa on-chain 기존 owner 서명 검증 → 온체인 SimpleAccount.owner = newOwnerEoa 실행
6 B서버 XOR re-split new private key: splitKeyXor3(newPrivateKey, new_pin_hash) → new share_server, new share_user 새 프라이빗키 XOR 재분할: splitKeyXor3(newPrivateKey, new_pin_hash) → 새 share_server, 새 share_user
7 B서버 Atomic DB update: Key[UUID].b_key ← new share_server, Key[UUID_MN].b_key ← AES256(new mnemonic), Wallet.owner_eoa ← new EOA address 원자적 DB 업데이트: Key[UUID].b_key ← 새 share_server, Key[UUID_MN].b_key ← AES256(새 니모닉), Wallet.owner_eoa ← 새 EOA 주소
8 B서버 Secure cleanup: wipe all key buffers (Buffer.fill(0)); return { address (unchanged), mnemonic_enc, key_share_user } 보안 정리: 모든 키 버퍼 초기화 (Buffer.fill(0)); { address (불변), mnemonic_enc, key_share_user } 반환

Key Rotation Sequence (Email Recovery) 키 교체 시퀀스 (이메일 복구)

sequenceDiagram participant U as User (Browser) participant W as webxcom (Rails) participant A as A서버 (NestJS) participant B as B서버 (Blockchain) participant DB as B서버 DB participant EP as EntryPoint participant SA as SimpleAccount U->>W: Request key recovery (lost PIN + mnemonic) W->>W: Send email verification code U->>W: Enter verification code + new PIN W->>A: POST /v1/pin/recover { user_uuid, new_pin_hash, new_pin_salt } A->>B: gRPC RecoverWalletKeyWithPin({ account_idx, new_pin_hash }) B->>DB: Load share_server + decrypt mnemonic DB-->>B: old state loaded B->>B: Derive old owner EOA from mnemonic
Validate vs Wallet.owner_eoa B->>B: Ethers.Wallet.createRandom()
→ new mnemonic, new privateKey, newOwnerEoa B->>B: Build UserOp: changeOwner(newOwnerEoa)
Old owner EOA signs UserOp B->>EP: handleOps([userOp]) EP->>SA: validateUserOp (old owner sig) SA-->>EP: valid ✓ EP->>SA: execute → changeOwner(newOwnerEoa) SA->>SA: owner = newOwnerEoa EP-->>B: Transaction receipt ✓ B->>B: splitKeyXor3(newPrivateKey, new_pin_hash)
→ new share_server, new share_user B->>DB: Key[UUID].b_key = new share_server
Key[UUID_MN].b_key = AES256(new mnemonic)
Wallet.owner_eoa = newOwnerEoa B->>B: All key buffers wiped (Buffer.fill(0)) B-->>A: { address (unchanged), mnemonic_enc, key_share_user } A-->>W: { address, mnemonic, key_share_user } W->>W: session[:key_share_user] = new key_share_user
session[:pin_hash] = new_pin_hash W-->>U: Show new mnemonic (one-time display) Note over SA: AA wallet address UNCHANGED
Only the signing key rotated
ℹ️ Key Insight — Address Permanence ℹ️ 핵심 — 주소 영속성
The AA wallet address never changes. Only the signing key (owner EOA) changes. The user keeps the same on-chain identity forever — all past ownership records, slot associations, and transaction history remain linked to the same SimpleAccount address. AA 지갑 주소는 절대 변경되지 않습니다. 서명 키(owner EOA)만 변경됩니다. 사용자는 영구적으로 동일한 온체인 신원을 유지합니다 — 모든 과거 소유권 기록, 슬롯 연결, 트랜잭션 이력이 동일한 SimpleAccount 주소에 연결된 상태를 유지합니다.
⚠️ Cross-Layer Consistency ⚠️ 레이어 간 일관성
The key rotation is not atomic across on-chain and off-chain layers. If the on-chain changeOwner succeeds but the DB transaction fails, the system reconciles by checking SimpleAccount.owner on-chain before any subsequent operation. B서버 always verifies the on-chain owner matches the DB owner_eoa at the start of recover_wallet_key_with_pin and will recompute the AA address if needed. 키 교체는 온체인과 오프체인 레이어 간에 원자적이지 않습니다. 온체인 changeOwner가 성공하고 DB 트랜잭션이 실패하면, 이후 작업 전에 온체인 SimpleAccount.owner를 확인하여 조정합니다. B서버는 recover_wallet_key_with_pin 시작 시 온체인 소유자와 DB owner_eoa가 일치하는지 항상 검증하며, 필요하면 AA 주소를 재계산합니다.

The on-chain state before and after rotation: 교체 전후의 온체인 상태:

// Before rotation
SimpleAccount (0xAA11...):
  owner = 0xOLD_EOA_111...  (old signing key — reconstructed via old XOR shares)

// After rotation
SimpleAccount (0xAA11...):              ← ADDRESS UNCHANGED
  owner = 0xNEW_EOA_222...  (new signing key — new XOR 3-of-3 split)

// B서버 DB after rotation
Key[UUID].b_key        = new_share_server  (old share_server overwritten)
Key[UUID_MN].b_key     = AES256(new_mnemonic)
Wallet.owner_eoa       = 0xNEW_EOA_222...

// Browser session after rotation
session[:key_share_user] = new_share_user  (old share_user invalidated)
session[:pin_hash]        = new_pin_hash

// All on-chain references to 0xAA11... remain valid
// Slot ownership, token balances, approvals — all preserved

Recovery Options Summary 복구 옵션 요약

Scenario 시나리오 Method 방법 Owner EOA Owner EOA On-chain tx? 온체인 트랜잭션?
Forgot PIN, have mnemonic PIN 분실, 니모닉 보유 POST /v1/pin/reset Unchanged 변경 없음 No 아니오
Forgot PIN + mnemonic, or key compromise PIN + 니모닉 분실, 또는 키 유출 의심 POST /v1/pin/recover New (rotated) 새로 교체 Yes — changeOwner 예 — changeOwner