---
title: PRD — TON Wallet Ownership Proof
tags: [prd, ton, wallet, security, telegram]
created: 2026-05-24
status: backend-implemented
priority: high
---

# PRD — TON Wallet Ownership Proof

## Background

TON wallet connect was shipped as a first-pass integration: the backend validates address format and stores it, but does not require the user to prove they control the private key behind the address. Any user can submit any valid-looking TON address and it will be saved to their profile.

This matters for:
- **Seller payout routing** — if a seller's wallet address was spoofed, funds go to the wrong address.
- **On-chain proof of identity** — downstream features (NFT-gated access, on-chain reputation) require a real ownership proof.
- **Compliance** — KYC/AML flows that tie a real-world identity to a wallet need proof, not self-report.

Until proof is implemented, saved TON wallet addresses are self-reported metadata only and must not be used for automatic payout routing.

---

## Goal

Replace the format-only TON wallet save with a full TonConnect ownership proof flow:
- Backend issues a challenge nonce scoped to the user session.
- Frontend passes the challenge to TonConnect UI, which asks the user to sign it.
- Backend verifies the returned proof against the wallet's public key.
- On success, stores `walletProofVerified: true`, proof timestamp, and wallet provider.

---

## Non-Goals

- On-chain transaction signing or payment initiation (separate feature).
- Requiring proof for EVM wallets (already covered by `ethers.verifyMessage`).
- TON DNS or NFT resolution.
- Mandatory wallet for all users (wallet remains optional profile metadata).

---

## User Stories

| As a… | I want to… | So that… |
|-------|-----------|----------|
| Seller | Connect my Telegram Wallet with proof | Payments route to the right address |
| Buyer | Know my counterparty's wallet is verified | I trust the seller's payout address |
| Admin | See `walletProofVerified` on user profiles | I can audit wallet verification status |
| Developer | Have a clear nonce/challenge API | I can build additional proof-gated features |

---

## Flow

```
Frontend                          Backend                       TonConnect
   |                                 |                               |
   |-- POST /wallet-address/ton-proof/challenge -->                 |
   |                                 |-- generate nonce             |
   |                                 |-- store nonce (TTL 5 min)    |
   |<-- { payload, domain, timestamp } --                           |
   |                                 |                               |
   |-- tonConnectUI.connectWallet({ tonProof: payload }) ---------->|
   |                                 |                    |-- user signs proof
   |<-- proof object (wallet, stateInit, signature) ---------------|
   |                                 |                               |
   |-- PATCH /wallet-address { walletType:'ton', tonProof } -->    |
   |                                 |-- verify proof               |
   |                                 |   - reconstruct message      |
   |                                 |   - verify against pubkey    |
   |                                 |   - check domain + nonce     |
   |                                 |-- store wallet + proof meta  |
   |<-- { walletProofVerified: true } --                            |
```

---

## Backend Changes

### 1. New endpoint: `POST /api/user/wallet-address/ton-proof/challenge`

**Auth:** Bearer JWT required.

**Request body:** none.

**Response:**
```json
{
  "payload": "aGV4LWVuY29kZWQtbm9uY2U...",
  "domain": "amn.gg",
  "timestamp": 1716560000
}
```

**Implementation:**
- Generate 32-byte random nonce, hex-encode.
- Store in a TTL map (or Redis `SET NX EX 300`) keyed by `userId`.
- Return `{ payload: nonce, domain: "amn.gg", timestamp: now }`.
- One active challenge per user — issuing a new one invalidates the old.

---

### 2. Updated: `PATCH /api/user/wallet-address`

**When `walletType = 'ton'` and a `tonProof` object is present:**

Accept additional body field:
```ts
tonProof?: {
  timestamp: number;
  domain: { lengthBytes: number; value: string };
  signature: string;          // base64
  payload: string;            // the nonce from challenge
  stateInit?: string;         // base64, for wallets not yet deployed
}
```

**Verification logic (using `@ton/ton` and `@ton/crypto`):**

```ts
import { Address, Cell, beginCell, contractAddress } from '@ton/ton';
import nacl from 'tweetnacl';

// 1. Validate nonce matches stored challenge for this user
// 2. Validate timestamp is within 5-minute window
// 3. Validate domain === 'amn.gg'
// 4. Reconstruct the signed message:
const message = Buffer.concat([
  Buffer.from('ton-proof-item-v2/'),
  Buffer.from(new Uint32Array([domain.length]).buffer),
  Buffer.from(domain, 'utf8'),
  Buffer.from(new Uint64Array([timestamp]).buffer),
  Buffer.from(payload, 'hex'),
]);
const hash = Buffer.from(sha256(Buffer.concat([
  Buffer.from([0xff, 0xff]),
  Buffer.from('ton-connect'),
  sha256(message),
])));
// 5. Recover public key from stateInit if wallet not deployed,
//    or fetch from blockchain (tonweb/lite-client) if deployed.
// 6. Verify: nacl.sign.detached.verify(hash, sig, pubkey)
// 7. Verify: Address.parse(walletAddress) matches derived address
```

**On success:**
- Store wallet fields: `profile.walletAddress`, `profile.walletType = 'ton'`, `profile.walletProvider`.
- Set new fields: `profile.walletProofVerified = true`, `profile.walletProofTimestamp = new Date()`.
- Clear the stored nonce.

**On failure:**
- Return `400 INVALID_TON_PROOF`.
- Do not update wallet fields.
- Do not clear the nonce (allow retry within TTL).

---

### 3. User model additions

```ts
// models/User.ts — inside profile
walletProofVerified?: boolean;
walletProofTimestamp?: Date;
```

---

### 4. Dependencies to add

```bash
yarn add @ton/ton @ton/crypto tweetnacl
```

`@ton/ton` is already a known dependency (the `@ton/core` frontend package is related). The backend needs `@ton/ton` for address parsing and cell operations, and `tweetnacl` for ed25519 verification.

---

## Frontend Changes

### 1. Fetch challenge before connecting

In `TonWalletProvider` (or the connect handler in `account-wallet-connection.tsx`):

```ts
// Before calling tonConnectUI.connectWallet()
const { payload } = await api.post('/api/user/wallet-address/ton-proof/challenge');

// Pass proof request to TonConnect
const proof: ConnectAdditionalRequest = {
  tonProof: payload,
};
await tonConnectUI.connectWallet(proof);
```

### 2. Extract and send proof after connection

```ts
// After connection, wallet.connectItems.tonProof is populated
const wallet = tonConnectUI.wallet;
if (!wallet?.connectItems?.tonProof || 'error' in wallet.connectItems.tonProof) {
  throw new Error('TON proof not returned by wallet');
}

await api.patch('/api/user/wallet-address', {
  walletAddress: wallet.account.address,
  walletType: 'ton',
  walletProvider: wallet.device.appName,
  tonProof: wallet.connectItems.tonProof.proof,
});
```

### 3. Show proof badge on wallet page

When `profile.walletProofVerified = true`, show a "Verified" badge next to the wallet address. When false (or absent), show "Unverified" with a "Verify ownership" button.

---

## Data Model

```ts
// Addition to profile sub-schema
walletProofVerified?: boolean;   // true after successful TonConnect proof
walletProofTimestamp?: Date;     // when proof was last verified
```

Existing fields retain their meaning:
- `walletAddress` — the TON address (friendly format)
- `walletType` — `'ton'` | `'evm'`
- `walletProvider` — e.g. `'Telegram Wallet'`, `'Tonkeeper'`

---

## API Reference

| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| POST | `/api/user/wallet-address/ton-proof/challenge` | JWT | Get nonce for proof |
| PATCH | `/api/user/wallet-address` | JWT | Save wallet + verify proof |

---

## Tests

### Backend
- Challenge endpoint returns `{ payload, domain, timestamp }`.
- Challenge is bound to userId (different users get different nonces).
- Challenge expires: re-issuing invalidates previous.
- Wallet save with valid proof sets `walletProofVerified = true`.
- Wallet save with expired nonce returns 400.
- Wallet save with wrong domain returns 400.
- Wallet save with bad signature returns 400.
- Wallet save without `tonProof` object sets `walletProofVerified = false` (legacy format-only path).

### Frontend
- `connectWallet` is called with `tonProof: payload`.
- If proof is absent in response, shows error and does not call save endpoint.
- If backend returns 400, shows "Wallet verification failed" toast.
- Verified wallet shows badge; unverified shows "Verify" prompt.

---

## Migration

Existing users with a saved TON wallet address will have `walletProofVerified` absent (undefined / falsy). They should be prompted to re-verify when they next visit the wallet page. No automatic migration needed — the address is retained, only proof status changes.

---

## Implementation Status

### Backend — Complete (2026-05-24)

- `POST /api/user/wallet-address/ton-proof/challenge` implemented in `userController.ts`.
- Full ed25519 verification in `src/services/user/tonProofService.ts`:
  - In-memory TTL challenge store (5-minute TTL per userId).
  - Nonce cleared on success only (allows retry on failure).
  - Verification: nonce match → expiry → domain → timestamp ±300s → stateInit hash matches address → extract pubkey → reconstruct signed message → `nacl.sign.detached.verify`.
  - Public key extraction handles standard wallet v3/v4 data layout (skip 64 bits seqno+subwallet, read 32-byte pubkey).
- User model fields added: `profile.walletProofVerified`, `profile.walletProofTimestamp`.
- Route registered: `POST /api/user/wallet-address/ton-proof/challenge`.
- `@ton/ton@16.2.4`, `@ton/crypto@3.3.0`, `tweetnacl@1.0.3` added to backend `package.json`.
- 15 backend unit tests in `__tests__/ton-proof.test.ts` — all passing.

### Frontend — Pending

- Challenge fetch + `setConnectRequestParameters` + proof extraction not yet wired.
- Verified/unverified badge not yet rendered.
- EVM wallet stub card added (see below).

---

## Acceptance Criteria

- [x] `POST /api/user/wallet-address/ton-proof/challenge` returns a valid nonce object.
- [ ] `PATCH /api/user/wallet-address` with a real TonConnect proof from Telegram Wallet succeeds and sets `walletProofVerified: true`.
- [x] A replay of the same proof is rejected.
- [x] An expired nonce is rejected.
- [x] A proof with a mismatched domain is rejected.
- [ ] The wallet page shows "Verified" badge when `walletProofVerified = true`.
- [ ] The wallet page shows "Verify ownership" CTA when wallet is saved but unverified.
- [x] Backend tests cover all rejection cases with mocked proofs.
- [x] `@ton/ton` version is pinned in `package.json` (TON SDK breaks semver occasionally).

---

## Related

- [[Handoff - Telegram Mini App Debug - 2026-05-24]]
- [[Security Audit - 2026-05-24]] — TON wallet is currently self-reported metadata
- [[Data Model Overview]]
- TON Connect docs: https://docs.ton.org/develop/dapps/ton-connect/sign
