# Wallet Easy Recovery (https://dev.cosmospay.lat/docs/api/wallet-easy-recovery/)

> Move a wallet between two devices through a five-minute mailbox this service cannot open.

Base URL: `https://api.cosmospay.lat/cosmos-api`

## Open a session on the new device

`POST /v1/wallet/easy-recovery/sessions`

Send a fresh X25519 public key; keep its private half on the device. Show a QR with the returned `id` and that key: nothing else, no secret and no server URL. The session lives five minutes.

Requires the `payments:write` scope. The shared public key may call it.

**Request body** (`application/json`, required): `CreateEasyRecoverySessionDto`

- `receiverKey` (string, required): The new device’s X25519 public key: 32 bytes, base64url without padding (43 characters). Generate a fresh key pair for every session and keep the private half on the device. The old device seals the wallet to this key, so it reads it from the QR, never from this service.

**Responses**

- `201` (`EasyRecoverySessionCreatedEntity`): Created
  - `id` (string, required): The session id: 16 random bytes, base64url (22 characters). Put it in the QR next to `receiverKey`, and nothing else from here.
  - `expiresAt` (string, required): When the session stops answering, ISO 8601. Five minutes.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `429`: RateLimited (see Shared responses)
- `500`: InternalError (see Shared responses)

## Poll a session

`GET /v1/wallet/easy-recovery/sessions/{id}`

Both devices poll it: the new one waits for `offered` to ask for the code, the old one for `claimed` to stop showing it. Answers the status and the expiry only, never the key, the sealed wallet or the code. An unknown, expired or burned session is a 404.

Requires the `payments:read` scope. The shared public key may call it.

**Path parameters**

- `id` (string, required): The session id from `POST /v1/wallet/easy-recovery/sessions`, as the QR carries it: 22 base64url characters.

**Responses**

- `200` (`EasyRecoverySessionStatusEntity`): OK
  - `status` (string, required): `open`: waiting for the old device. `offered`: the old device has sent the sealed wallet and shows the code, which the new device asks for it. `claimed`: the new device collected it; the old device can stop. One of: `open`, `offered`, `claimed`.
  - `expiresAt` (string, required): When the session stops answering, ISO 8601.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404` (`ApiErrorBodyEntity`): `easy_recovery_not_found`: No such session, or it expired, was claimed or was burned
  - `statusCode` (number, required): Always the HTTP status of the response itself.
  - `code` (string, required): Stable machine-readable code. Branch on this, never on the message: messages are human-facing and may be reworded; codes are never renamed once published. Each response documents the subset it can return. One of: `insufficient_scope`, `no_authenticated_consumer`, `gateway_required`, `admin_console_only`, `not_found`, `validation_failed`, `payload_too_large`, `idempotency_conflict`, `operation_in_flight`, `invalid_state_transition`, `slippage_exceeded`, `insufficient_balance`, `no_path_found`, `trustline_missing`, `invalid_amount`, `invalid_memo`, `transaction_rejected`, `transaction_already_settled`, `recipient_not_registered`, `payment_transfer_mismatch`, `fee_transfer_mismatch`, `merchant_fee_transfer_mismatch`, `payer_mismatch`, `transaction_not_found`, `expired`, `network_unsupported`, `asset_unsupported`, `merchant_fee_unsupported`, `provider_error`, `provider_unavailable`, `quote_not_found`, `quote_expired`, `kyc_state_invalid`, `kyc_review_required`, `account_disabled`, `payload_expired`, `rate_limited`, `customer_wallet_taken`, `alias_taken`, `alias_name_invalid`, `alias_challenge_invalid`, `alias_signature_invalid`, `alias_recovery_invalid`, `alias_address_conflict`, `wallet_handshake_invalid`, `wallet_verifier_invalid`, `wallet_login_code_invalid`, `wallet_login_code_cooldown`, `wallet_session_invalid`, `wallet_signature_invalid`, `wallet_backup_invalid`, `wallet_backup_limit`, `wallet_account_mismatch`, `wallet_provider_unavailable`, `wallet_return_url_not_allowed`, `wallet_recovery_setup_refused`, `easy_recovery_not_found`, `easy_recovery_already_offered`, `easy_recovery_not_offered`, `easy_recovery_code_invalid`, `easy_recovery_burned`, `plugin_not_installed`, `plugin_consent_mismatch`, `plugin_rejected`, `plugin_quota_exceeded`, `plugin_failed`, `protected_payment_buyer_mismatch`, `protected_payment_signature_invalid`, `misconfigured`, `internal_error`.
  - `error` (string, required): The HTTP reason phrase for `statusCode`.
  - `message` (string | string[], required): Human-readable detail. An array when class-validator rejected the body.
  - `details` (object, optional): Machine-readable facts about this failure, present only on the codes whose example shows it: `easy_recovery_code_invalid` carries `{ "attemptsLeft": 4 }`. Absent everywhere else.
  - `path` (string, required): The path of the request that failed.
  - `timestamp` (string, required): When the envelope was produced, ISO 8601.
- `429`: RateLimited (see Shared responses)
- `500`: InternalError (see Shared responses)

## Offer the sealed wallet from the old device

`POST /v1/wallet/easy-recovery/sessions/{id}/offer`

After the person confirmed and unlocked on the old device: seal the wallet to the `receiverKey` read from the QR (ECIES, on the device), generate a random five-digit code, send both here and show the code. Accepted once per session, while it is `open`: the first offer wins and any later one is a 409. Only a hash of the code is stored.

Requires the `payments:write` scope. The shared public key may call it.

**Path parameters**

- `id` (string, required): The session id from `POST /v1/wallet/easy-recovery/sessions`, as the QR carries it: 22 base64url characters.

**Request body** (`application/json`, required): `OfferEasyRecoveryDto`

- `ciphertext` (string, required): The wallet’s secret sealed on the old device to the session’s `receiverKey` (ECIES), base64url. This service stores it as given, cannot open it, and hands it out once, to the right code.
- `code` (string, required): The five-digit code the old device generated at random and shows on its own screen. Only its hash is stored.

**Responses**

- `200` (`EasyRecoveryOfferedEntity`): OK
  - `status` (string, required): Always `offered`. One of: `offered`.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404` (`ApiErrorBodyEntity`): `easy_recovery_not_found`: No such session, or it expired, was claimed or was burned
- `409` (`ApiErrorBodyEntity`): `easy_recovery_already_offered`: The session already holds an offer; the first one wins
- `429`: RateLimited (see Shared responses)
- `500`: InternalError (see Shared responses)

## Claim the sealed wallet with the code on the old device

`POST /v1/wallet/easy-recovery/sessions/{id}/claim`

The right code answers the sealed wallet, once: the session reads `claimed` from then on and no longer holds it. A wrong code is a 422 with `details.attemptsLeft`; the fifth wrong code deletes the session and answers 410. Before the old device offered, the answer is 409: poll for `offered` first. A malformed code is a 400 and costs no attempt.

Requires the `payments:write` scope. The shared public key may call it.

**Path parameters**

- `id` (string, required): The session id from `POST /v1/wallet/easy-recovery/sessions`, as the QR carries it: 22 base64url characters.

**Request body** (`application/json`, required): `ClaimEasyRecoveryDto`

- `code` (string, required): The five-digit code the person read off the old device. A malformed one is a 400 and costs no attempt; a wrong one costs one of five.

**Responses**

- `200` (`EasyRecoveryClaimedEntity`): OK
  - `ciphertext` (string, required): The sealed wallet exactly as the old device sent it, base64url. Open it with the private key of `receiverKey`. Handed out once: the session reads `claimed` from now on and no longer holds it.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404` (`ApiErrorBodyEntity`): `easy_recovery_not_found`: No such session, or it expired, was claimed or was burned
- `409` (`ApiErrorBodyEntity`): `easy_recovery_not_offered`: Nothing has been offered on the session yet
- `410` (`ApiErrorBodyEntity`): `easy_recovery_burned`: That was the last wrong code allowed: the session was deleted
- `422` (`ApiErrorBodyEntity`): `easy_recovery_code_invalid`: Wrong code; `details.attemptsLeft` says how many more the session survives
- `429`: RateLimited (see Shared responses)
- `500`: InternalError (see Shared responses)

## Shared responses

- **ValidationFailed** (`ApiErrorBodyEntity`): The body, query or path failed validation. `message` is an array of the individual failures. Routes that reject a *valid* request for a reason of their own (an amount, a memo, a state) document the code for it on their own 400.
- **Unauthorized** (`ApiErrorBodyEntity`): `no_authenticated_consumer`: The gateway forwarded no API key identity
- **Forbidden** (`ApiErrorBodyEntity`): `insufficient_scope`: the API key does not hold the scope this route requires. The shared public API key also gets this on every route that is not marked `x-cosmos-public-key`, with a message saying so; more scopes will not help, the key is the wrong one. `gateway_required`: the request did not arrive through APISIX.
- **RateLimited** (`ApiErrorBodyEntity`): The budget in `x-cosmos-rate-limit` is spent for this caller. Honour `Retry-After`. The `ratelimit-*` triple rides along on successful responses too, so a client can pace itself instead of being refused. If the counter store itself is unreachable the guard fails closed and answers `503 provider_unavailable`.
- **InternalError** (`ApiErrorBodyEntity`): Unexpected server error. The detail is logged server-side and never returned: there is nothing in the body to branch on beyond the code.
