--- 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