Docs
v1

Reference

TCC-0004 Address ownership proof

How the owner of a Tenrai address proves control to a verifier. The request and proof documents, the signed message and the checks in order.

On this page
NumberTCC-0004
StatusFinal
CategoryWallet
IdentifierDocument version 1, context TRAI/ADDRESS/PROOF/1

This convention specifies how the owner of a Tenrai address proves it to a verifier, version 1. The verifier sends a request with a random challenge; the owner's wallet signs it with the ML-DSA-65 key behind the address. The proof is never part of a transaction and moves no funds.

Request

JSON
{
  "type": "tenrai-address-proof-request",
  "version": 1,
  "address": "trai1...",
  "challenge": "<64 lower-case hex>",
  "expires_at": 1900000000
}
FieldRule
typeExactly tenrai-address-proof-request
version1
addressCanonical lower-case Bech32m address of a single-key lock (profile 1)
challenge32 bytes from a cryptographic random source, 64 lower-case hex characters
expires_atUnix time in seconds after which the proof no longer counts, at most 2^53 − 1 so JavaScript reads it exactly

The verifier keeps each challenge until it expires and accepts it once.

Proof

JSON
{
  "type": "tenrai-address-proof",
  "version": 1,
  "address": "trai1...",
  "challenge": "<64 lower-case hex>",
  "expires_at": 1900000000,
  "public_key": "<3904 lower-case hex>",
  "signature": "<6618 lower-case hex>"
}

address, challenge and expires_at repeat the request. public_key is the 1,952-byte ML-DSA-65 key of the address, signature the 3,309-byte signature. Both documents are JSON objects without further fields; parsers refuse unknown fields, upper-case hex and other lengths.

Signed message

Text
message   = sha384_domain_hash("TRAI_ADDRESS_PROOF_V1",
                               [ hrp, address_version, lock,
                                 challenge, u64_be(expires_at) ])

signature = ML-DSA-65.Sign(secret_key, message, context = "TRAI/ADDRESS/PROOF/1")

hrp, address_version, lock and sha384_domain_hash are those of TCC-0003. The message is the 48-byte digest. The context belongs to no other signature, so a spend or staking signature is never a proof, and a proof never authorizes a spend.

Verification

A verifier checks, in this order:

  1. The address is a single-key address (lock profile 1). Otherwise not-single-key.
  2. SHAKE256("TRAI_LOCK_V1" || 0x01 || public_key) read to 40 bytes equals the lock commitment. Otherwise key-mismatch.
  3. The current time in seconds is at most expires_at. Otherwise expired.
  4. The signature verifies under the message and context above. Otherwise bad-signature.

With the request at hand, the verifier also checks that address, challenge and expires_at of the proof equal those of the request.

Threshold addresses have no single key and cannot be proven this way.

Wallets

A wallet answers a request only before expires_at, shows the address, its verification words and the time left, and signs once the user agrees. It looks the address up among its tracked addresses; the command-line wallet also searches the first 10,000 receive and change addresses of an account.

Vectors

crates/wallet/tests/vectors/address-proof-v1.json in the node repository holds requests and proofs with fixed keys and signing randomness, the 48-byte message, a time and the expected result: valid, expired, key-mismatch, bad-signature or not-single-key.

Type to search guides, concepts and reference pages.