Files
nick-doc/07 - Development/PRD - TON Wallet Ownership Proof.md
Siavash Sameni 873a57874e Update docs for TON proof backend, Mini App fixes, and EVM wallet stub
PRD - TON Wallet Ownership Proof.md:
- Status updated from ready-to-implement -> backend-implemented.
- Added Implementation Status section documenting what is complete
  (challenge endpoint, tonProofService.ts, User model fields, 15 tests)
  and what remains (frontend proof wiring, verified badge).
- Acceptance criteria updated: backend items checked, frontend pending.

Handoff - Telegram Mini App Debug - 2026-05-24.md:
- New Implemented sections for session 3:
  - TON Wallet Ownership Proof backend (full detail of tonProofService,
    userController changes, User model fields, 15 unit tests).
  - Telegram Mini App shell dir="ltr" + smart displayName fallback.
  - Socket status suppressed on /telegram paths.
  - EVM WalletConnect stub card (disabled until project ID configured).
- Known Issues: TON proof updated to "frontend wiring pending";
  EVM WalletConnect section added with activation steps.
- Current Git State updated to Session 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 20:21:29 +04:00

11 KiB

title, tags, created, status, priority
title tags created status priority
PRD — TON Wallet Ownership Proof
prd
ton
wallet
security
telegram
2026-05-24 backend-implemented 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:

{
  "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:

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

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

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

4. Dependencies to add

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

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

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

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

  • 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.
  • A replay of the same proof is rejected.
  • An expired nonce is rejected.
  • 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.
  • Backend tests cover all rejection cases with mocked proofs.
  • @ton/ton version is pinned in package.json (TON SDK breaks semver occasionally).