# X-Multi Auth v1 — Authentication and Signing for the XRP Ledger

**Version:** 1.1.0
**Author:** X-Multi (xmulti.app)
**Date:** April 2026
**Status:** Stable, additive change from 1.0.0-draft (no breaking changes for verifiers; new optional fields)

---

## Table of contents

1. [Problem](#problem)
2. [Two protocols, one spec](#two-protocols-one-spec)
3. [Protocol A — VAULT_AUTH (sign-in proofs)](#protocol-a--vault_auth-sign-in-proofs)
   - [Transaction format](#transaction-format)
   - [Account types — vault and personal](#account-types--vault-and-personal)
   - [Verification flow](#verification-flow)
   - [Public verification API](#public-verification-api)
   - [JavaScript verification snippet](#javascript-verification-snippet)
4. [Protocol B — Cross-device sign requests (per-tx signing)](#protocol-b--cross-device-sign-requests-per-tx-signing)
   - [Lifecycle](#lifecycle)
   - [Create endpoint](#create-endpoint)
   - [Read endpoint](#read-endpoint)
   - [Submit endpoint](#submit-endpoint)
   - [Phone-side review page](#phone-side-review-page)
5. [Method C — Signer proxy (complementary access pattern)](#method-c--signer-proxy-complementary-access-pattern)
6. [Security considerations](#security-considerations)
7. [When to use which](#when-to-use-which)
8. [Adoption](#adoption)

---

## Problem

Two gaps in XRPL dApp UX motivate this spec:

1. **Multisig sign-in is mathematically impossible with off-ledger signatures.** Every XRPL dApp login asks for a single-key signature. Multisig wallets — where signing authority is distributed across multiple signers — cannot produce one. If the master key is blackholed (common for community-takeover projects), no off-ledger signature is possible at all. Community treasuries, DAO vaults, and CTO project wallets are locked out of dApps.
2. **Per-transaction signing requires a wallet extension or app.** Personal r-address users typically rely on Xaman, Crossmark, GemWallet, or a desktop wallet to sign individual transactions. dApps want a third option that doesn't require the user to install a specific wallet.

X-Multi Auth solves both, on-ledger, with one spec.

## Two protocols, one spec

| Protocol | What it does | Account types | Latency |
|---|---|---|---|
| **A — VAULT_AUTH** | Mints a sign-in proof on-chain | Multisig vault OR personal r-address | seconds (personal) to minutes/hours (vault, signers must coordinate) |
| **B — Sign requests** | Cross-device per-tx signing via QR | Personal r-address only | seconds |
| **C — Signer proxy** | Off-chain access for vault tooling | Personal r-address (must be in vault's SignerList) | instant |

A given dApp can use any combination. A common pattern: VAULT_AUTH for sign-in (works for both account types via @x-multi/sdk), then sign requests for per-tx signing once the user is logged in.

---

## Protocol A — VAULT_AUTH (sign-in proofs)

A short XRPL transaction signed by the account, carrying a session memo, recorded on the validated ledger. Any dApp can verify it directly from any rippled node.

### Transaction format

```json
{
  "TransactionType": "AccountSet",
  "Account": "rACCOUNT_ADDRESS",
  "Memos": [{
    "Memo": {
      "MemoType": "782d6d756c74692f61757468",
      "MemoData": "<hex-encoded JSON session>"
    }
  }],
  "Fee": "12 (personal) | 200 (vault)",
  "Signers": "[ ... ]  // present only for vault proofs"
}
```

- `MemoType` is the hex encoding of the literal string `x-multi/auth`.
- `MemoData` is the hex encoding of:

```json
{
  "session": "<UUID v4>",
  "domain":  "<dApp's hostname>",
  "vault":   "<rACCOUNT_ADDRESS>",
  "created": "<ISO 8601 UTC timestamp>",
  "expires": "<ISO 8601 UTC timestamp>"
}
```

(The field is named `vault` for historical reasons in v1 — for personal proofs it carries the user's own r-address.)

### Account types — vault and personal

The same spec covers both signing patterns. The difference is `tx.Signers`:

- **`account_type: "vault"`** — `tx.Signers` is non-empty. The transaction is multisig-signed by the account's SignerList. The proof represents authorization by the vault's quorum.
- **`account_type: "personal"`** — `tx.Signers` is empty. The transaction is single-sig signed by the account's master/regular key. The proof represents authorization by that single user.

Verifiers derive the type from the presence/absence of `tx.Signers`:

```javascript
const accountType = (tx.Signers ?? []).length > 0 ? 'vault' : 'personal'
```

dApps that only accept one of the two should pass `restrict=vault` or `restrict=personal` to `xmulti.app/sso` and reject the wrong type server-side. See [@x-multi/sdk's `restrictTo` option](https://www.npmjs.com/package/@x-multi/sdk).

### Verification flow

1. dApp receives a transaction hash from the user (via `@x-multi/sdk` postMessage, or via the `?proof=...` query param in redirect mode).
2. dApp fetches the tx: `{ "method": "tx", "params": [{ "transaction": "TX_HASH" }] }` (or hits `https://www.xmulti.app/api/verify/<txHash>`).
3. dApp checks:
   - `tx.validated === true` and `meta.TransactionResult === "tesSUCCESS"` — confirmed on-chain
   - `TransactionType === "AccountSet"`
   - Memo type is `x-multi/auth`
   - Session `domain` matches the dApp's expected domain
   - Session `expires` is in the future
   - `account_type` matches what the dApp accepts (if restricting)
4. If all checks pass, the account is authenticated.

**Replay protection** is the dApp's responsibility: store the `session` UUID in a one-shot table on first acceptance and reject re-use. See the [/integrate guide step 3](https://www.xmulti.app/integrate#step-3) for a Postgres example.

### Public verification API

X-Multi runs a free verification endpoint with open CORS:

```
GET https://www.xmulti.app/api/verify/{txHash}
```

Response (1.1.0, additive fields marked):

```json
{
  "verified": true,
  "account": "rACCOUNT_ADDRESS",                  // [1.1.0] canonical signing address
  "account_type": "vault" | "personal",           // [1.1.0] derived from tx.Signers
  "vault_address": "rACCOUNT_ADDRESS",            // [deprecated alias for `account`, 1.0.0 callers]
  "self_payment": true,                           // true for AccountSet (no Destination)
  "session": "uuid-v4",
  "domain":  "your-dapp.com",
  "created": "2026-04-27T12:00:00Z",
  "expires": "2026-04-27T14:00:00Z",
  "expired": false,
  "signers": ["rSIGNER1", "rSIGNER2"],            // empty array for personal proofs
  "tx_hash": "ABC123...",
  "ledger_index": 12345678
}
```

`vault_address` is kept as an alias for `account` so verifiers written against 1.0.0-draft keep working unchanged.

### JavaScript verification snippet

```javascript
async function verifyXMultiAuth(txHash, expectedDomain, allowedTypes = ['vault', 'personal']) {
  // Option A — use the X-Multi public verifier
  const res = await fetch(`https://www.xmulti.app/api/verify/${txHash}`)
  const proof = await res.json()

  if (!proof.verified) return { authenticated: false, error: proof.error }
  if (proof.domain !== expectedDomain) return { authenticated: false, error: 'Domain mismatch' }
  if (proof.expired)                   return { authenticated: false, error: 'Session expired' }
  if (!allowedTypes.includes(proof.account_type)) {
    return { authenticated: false, error: `Account type ${proof.account_type} not accepted` }
  }

  return {
    authenticated: true,
    account:       proof.account,
    accountType:   proof.account_type,
    signers:       proof.signers,           // empty for personal
    session:       proof.session,
  }

  // Option B — verify directly against XRPL (no X-Multi dependency at all)
  // Fetch the tx via { method: 'tx', params: [...] }, parse Memos, derive
  // accountType from tx.Signers.length, validate session/domain/expires.
}
```

---

## Protocol B — Cross-device sign requests (per-tx signing)

dApps can request signatures for arbitrary XRPL transactions through X-Multi without requiring the user to install Xaman or any other wallet. The user scans a QR code on their phone (where they have the X-Multi PWA installed), reviews the transaction, signs locally, and submits to the XRPL. The dApp polls a public endpoint for the result.

**Personal accounts only.** Multisig vaults still need quorum coordination through the proposal flow at `xmulti.app/vaults/.../proposals` — that doesn't fit the scan-and-sign-once pattern.

### Lifecycle

```
   dApp (desktop)         X-Multi backend          User's phone
   ─────────────          ────────────────         ────────────
        │                       │                       │
  1. POST /api/sign-request     │                       │
        │ ──────────────────►   │                       │
        │ ◄────── { id, sign_url, expires_at }          │
        │                       │                       │
  2. Render sign_url as QR      │                       │
        │                       │                       │
        │                       │   3. user scans QR    │
        │                       │ ◄─────────────────────│
        │                       │  GET /api/sign-       │
        │                       │   request/[id]        │
        │                       │                       │
        │                       │   4. /sign/[id] page  │
        │                       │      shows tx review  │
        │                       │                       │
        │                       │   5. user signs       │
        │                       │      locally + submits│
        │                       │      to XRPL          │
        │                       │ ◄─────────────────────│
        │                       │  POST /api/sign-      │
        │                       │   request/[id]/submit │
        │                       │  { tx_hash }          │
        │                       │                       │
  6. dApp polls every 2s        │                       │
     GET /api/sign-request/[id] │                       │
        │ ──────────────────►   │                       │
        │ ◄── { status, tx_hash }                       │
```

State machine for a request row:

```
pending  → submitted   (user signed + submitted on phone)
pending  → rejected    (user explicitly cancelled)
pending  → expired     (lazy-marked when GET sees expires_at < now)
```

Once non-pending, the row is terminal.

### Create endpoint

```
POST https://www.xmulti.app/api/sign-request
Content-Type: application/json

{
  "domain":           "your-dapp.com",       // shown to user; used to display "Sign on behalf of <domain>"
  "session_id":       "<random string ≥8 chars>",  // dApp's one-shot replay token
  "unsigned_tx": {
    "TransactionType": "Payment",
    "Account":         "rUSER_ADDRESS",
    "Destination":     "rRECIPIENT",
    "Amount":          "1000000"
    // Sequence / Fee / LastLedgerSequence are filled in by /sign at sign time
  },
  "required_account": "rUSER_ADDRESS",       // optional — pin to a specific signer
  "ttl_seconds":      300                    // optional — default 300, min 30, max 3600
}
```

Response:

```json
{
  "id":         "01a2b3c4-...-d5e6f7g8",
  "sign_url":   "https://www.xmulti.app/sign/01a2b3c4-...-d5e6f7g8",
  "expires_at": "2026-04-27T12:05:00Z"
}
```

The dApp encodes `sign_url` as a QR using its preferred QR library (`qrcode`, `qrcode.react`, etc. — X-Multi's SDK does not bundle one).

CORS is open. Per-IP rate-limited.

### Read endpoint

```
GET https://www.xmulti.app/api/sign-request/{id}
```

Returns the current row state, used by both the `/sign/[id]` page on first load and the dApp's polling loop:

```json
{
  "id":              "01a2b3c4-...",
  "domain":          "your-dapp.com",
  "unsigned_tx":     { ... },
  "session_id":      "...",
  "required_account": "rUSER_ADDRESS",
  "status":          "pending" | "submitted" | "expired" | "rejected",
  "signed_blob":     "<hex blob>" | null,
  "tx_hash":         "ABC123..." | null,
  "error":           "<message>" | null,
  "created_at":      "2026-04-27T12:00:00Z",
  "expires_at":      "2026-04-27T12:05:00Z",
  "signed_at":       "2026-04-27T12:02:30Z" | null
}
```

Pending rows past their `expires_at` are flipped to `expired` lazily on the next read.

### Submit endpoint

The phone-side `/sign/[id]` page calls this after the user signs and the tx lands on-chain:

```
POST https://www.xmulti.app/api/sign-request/{id}/submit
Content-Type: application/json

{ "tx_hash": "ABC123...", "signed_blob": "<hex blob>" }   // success path
{ "error":   "user_rejected" }                            // cancel path
```

CAS-protected: only `pending` rows can transition to `submitted` or `rejected`. Re-submission of a terminal row returns 409.

### Phone-side review page

`https://www.xmulti.app/sign/{id}` is a regular X-Multi page. The user lands here from a QR scan, must be signed in to X-Multi, and is shown:

- The requesting `domain` (with a warning to verify it's the dApp they expect)
- The `unsigned_tx` fields (`From`, `To`, `Amount`, `Tag`, `NFTokenID`, etc., with raw fallback for unknown fields)
- The `expires_at` countdown
- Approve & Sign / Reject buttons

On approve, the page signs the tx with the user's local vault, submits it to the XRPL, waits for validation (~3–5s), and POSTs the resulting `tx_hash` back to the submit endpoint.

The dApp's poller sees `status: "submitted"` with a `tx_hash` and can verify the tx independently against any rippled node.

---

## Method C — Signer proxy (complementary access pattern)

A simpler approach for when the dApp wants to grant individual signers access to vault tooling without an on-chain proof. Useful for dashboards that read vault data but don't need to attribute actions to the vault itself.

### Flow

1. User logs into the dApp with their **personal wallet** (standard off-ledger signature — Xaman, GemWallet, etc., or VAULT_AUTH with `account_type: "personal"`).
2. User provides the vault address they want to access.
3. dApp queries the vault's SignerList:
   ```
   { "method": "account_objects", "params": [{ "account": "rVAULT", "type": "signer_list" }] }
   ```
4. dApp checks if the user's address is in `SignerEntries`.
5. If yes, grant a role based on weight:
   - `weight >= quorum` → Full access (can act alone if vault tooling allows)
   - `weight > 0` → Signer access (view + propose only)
   - Not in list → No access

### Snippet

```javascript
async function verifySignerProxy(userAddress, vaultAddress) {
  const res = await fetch('https://xrplcluster.com', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      method: 'account_objects',
      params: [{ account: vaultAddress, type: 'signer_list', ledger_index: 'validated' }],
    }),
  })
  const data = await res.json()
  const signerList = data.result?.account_objects?.[0]
  if (!signerList) return { authorized: false, error: 'No signer list' }

  const entry = signerList.SignerEntries?.find(e => e.SignerEntry.Account === userAddress)
  if (!entry) return { authorized: false, error: 'Not a signer on this vault' }

  return {
    authorized:   true,
    address:      userAddress,
    vault:        vaultAddress,
    weight:       entry.SignerEntry.SignerWeight,
    quorum:       signerList.SignerQuorum,
    canActAlone:  entry.SignerEntry.SignerWeight >= signerList.SignerQuorum,
  }
}
```

This gives instant verification with no on-chain action required, at the cost of a weaker trust model: the signer is authenticated as themselves, not as the vault.

---

## Security considerations

### Protocol A — VAULT_AUTH

- **Replay protection (dApp side):** Each session has a unique UUID. Store on first redemption; reject re-use.
- **Domain binding:** The session memo carries the dApp's domain. Verifiers MUST reject proofs minted for a different domain.
- **Expiry:** Default 2 hours. Verifiers MUST reject expired proofs even if they've never been seen.
- **Cost:** ~12 drops (personal) or ~200 drops (vault) per proof. Negligible.
- **Vault latency:** Vault proofs require quorum coordination — minutes to hours depending on signer availability. Personal proofs settle in 3–5 seconds.

### Protocol B — Sign requests

- **Possession of `id` is the security model.** The id is a UUID v4 (~122 bits of entropy). Brute-force is infeasible. The dApp shares it via QR / deep link the user scans; the user verifies the displayed `domain` before signing.
- **No SDK requires holding any X-Multi secret.** dApps create requests anonymously; rate-limiting is per-IP.
- **Replay:** The dApp's `session_id` is echoed back unchanged for one-shot enforcement on the dApp side. The on-chain tx itself may carry whatever replay protection the dApp embeds in memos.
- **TTL:** Default 5 minutes; configurable 30s–1h. Short TTLs reduce abandoned-request surface.

### Method C — Signer proxy

- **Trust model:** dApps trust the vault's on-chain SignerList as the source of truth. Same level of trust as any XRPL on-chain query.
- **No on-chain action:** Instant, free, but only authenticates the signer as themselves — not as the vault.

---

## When to use which

| Scenario | Recommended |
|---|---|
| Vault wants to log into a dApp (CTO project, DAO treasury, etc.) | Protocol A — VAULT_AUTH (vault) |
| Personal user wants to log in without a wallet extension | Protocol A — VAULT_AUTH (personal) |
| dApp wants instant XRPL identity verification | Method C — Signer proxy |
| Vault management dashboard for vault signers | Method C — Signer proxy |
| dApp needs the user to sign a transaction (payment, NFT offer, swap, etc.) without Xaman/extension | Protocol B — Sign request |
| Master key blackholed | Protocol A — VAULT_AUTH (vault) — the only option |

---

## Adoption

**Easy path** — install [@x-multi/sdk](https://www.npmjs.com/package/@x-multi/sdk):

```bash
npm install @x-multi/sdk
```

```javascript
import { signInWithXMulti, requestSignature } from '@x-multi/sdk'

// Sign-in (Protocol A)
const proof = await signInWithXMulti({ domain: window.location.hostname })
// proof.account, proof.accountType ('vault' | 'personal'), proof.session, proof.txHash

// Per-tx signing (Protocol B)
const handle = await requestSignature({
  domain:     window.location.hostname,
  sessionId:  crypto.randomUUID(),
  unsignedTx: { TransactionType: 'Payment', Account: proof.account, /* ... */ },
})
// Render handle.signUrl as a QR with your preferred QR lib
const { txHash } = await handle.result
```

**Manual path** — call the verify and sign-request endpoints directly. No npm dependency, ~20 lines per protocol.

**Signer proxy** — query SignerList directly via XRPL JSON-RPC. No X-Multi dependency at all.

---

## Contact

- **Website:** https://www.xmulti.app
- **Spec:** https://www.xmulti.app/specs/xmulti-auth-v1.md
- **GitHub:** https://github.com/DpacJones/X-Multi
- **Integration guide:** https://www.xmulti.app/integrate
