What runs on your phone, and what runs on ours
The wallet’s security rests on one Rust library and standard cryptography. The library does the 2-of-2 threshold signing. It runs on your phone as WebAssembly inside the Telegram Mini App and on our server as native code, and its source is public. Around it, Argon2id, HKDF-SHA-256 and AES-256-GCM decide who can open what. This page lists each part, where it runs, and its settings.
What runs where
| Part | Where it runs | What it does |
|---|---|---|
| Signing core | Your phone, as WebAssembly; our server, as a native library | Makes each vault’s key shares and the threshold signature, using frost-ed25519 in Rust |
| Mini App | Your phone, inside Telegram | Keeps the encrypted phrase, asks for the PIN, and checks every request before your share signs |
| Argon2id | Your phone, as WebAssembly | Stretches the PIN before anything made from it leaves the phone |
| WebCrypto | Your phone’s browser engine | AES-256-GCM encryption, ECDH, and the random numbers the phone uses |
| Server | Our servers | Holds our share, checks PINs, builds transfers, and publishes them to the chain |
Every key, and what it opens
| Key | Made from | What it protects |
|---|---|---|
| Phone storage key | HKDF-SHA-256 over the Argon2id output of your PIN, salted with a random secret our server releases, and bound to your account and phone | The recovery phrase kept on the phone |
| Share backup key | HKDF-SHA-256 over the recovery phrase, salted with a random value made for your account; a separate key for each vault | The backup of your key share on our server |
| Pairing key | ECDH on P-256 between your two phones, then HKDF-SHA-256 | The phrase on its way to a new phone |
| PIN keys | HKDF-SHA-256 over a server master key: one key for the check value, one for the secret it releases | The check value of your PIN, and the secret released for it |
| Server share keys | HKDF-SHA-256 over a second server master key: one for the database, one for the separate backup | Our key share, in the database and in its backup |
The signing core
The core is a small Rust library built on frost-ed25519. That is the Zcash Foundation’s implementation of FROST for Ed25519, the scheme published as RFC 9591. The core keeps no state of its own: every call is one step (a round of key generation, a commitment, a signature share, or the final aggregation) passed in and returned as JSON, and the caller carries the state between steps.
The same source is compiled twice. For your phone it becomes WebAssembly that the Mini App loads; for our server it becomes a native library the server calls through a C interface. Both sides run identical code, and each draws its randomness from its own platform: the operating system on the server, crypto.getRandomValues in the phone’s browser engine.
Aggregation checks its own output. After combining the two signature shares, the core verifies the result against the vault’s public key, and our server will not publish a signature that fails. A share made with the wrong key, or over the wrong message, stops there rather than on the chain.
Key generation, step by step
A vault’s key is created in three steps on each side. First, each side draws a random polynomial and publishes commitments to it, with a proof that it knows the secret behind them. Second, each sends the other a private value computed from its polynomial. Third, each combines what it received into its own share and computes the group’s public key.
Your phone compares its public key with the one our server reports and stops if they differ. It then encrypts its share with the backup key for that vault and uploads only the ciphertext. The share in the clear exists in the phone’s memory for as long as this takes, and nowhere else.
Signing, step by step
When you confirm a transfer, your phone downloads the encrypted backups of the vaults it pays from and opens them with keys made from your phrase. For each vault it draws one-time nonces and sends our server only the commitments to them. Our server builds the transfer message for each vault and returns it in plain form, together with the package to sign.
Your phone then hashes each disclosed message itself and compares the digest with the one in the package. It checks that each message is a single transfer from the vault whose share it holds, and that together they send no more than the total you approved. Only then does it produce its signature shares.
The nonces never leave the phone and are never used twice. Our server adds its own shares, aggregates, checks the signature, and publishes the transfer.
The PIN, step by step
Your PIN never leaves the phone. The phone runs it through Argon2id with a random salt stored only on that phone, at 3 passes over 64 MiB of memory. It sends our server the Argon2id output, and our server keeps only an HMAC-SHA-256 of that output, bound to your account and that phone.
To unlock, our server recomputes the HMAC and compares it in constant time. A match releases a random secret it stores encrypted for your account and phone; a mismatch counts as a wrong PIN. The phone mixes the Argon2id output with that secret through HKDF-SHA-256 into a non-extractable AES-256-GCM key, and that key opens the phrase.
Because the salt stays on the phone, our server cannot test PIN guesses against its own records: it would need the salt to reproduce Argon2id. That is what makes a PIN of at least 4 digits workable. Every guess has to go through our server, which makes each one wait longer, up to 24 hours, and the phone erases its copy after 6 wrong PINs.
Keys and ciphers
Every key in the table above passes through HKDF-SHA-256 under its own label, so no two jobs share key material. Everything the wallet encrypts, it encrypts with AES-256-GCM, which also catches tampering: a changed byte, or our share’s ciphertext copied into another vault’s row, fails to decrypt instead of producing garbage.
The recovery phrase is not stretched the way the PIN is. Its 128 bits of randomness are already out of reach of any search, so the backup keys come from HKDF over the phrase directly. Argon2id is there for the PIN, which a person picks and a machine could otherwise guess.
Where secrets sit in memory
The decrypted phrase lives in a single variable while the wallet is unlocked. It stays out of the interface’s reactive state, is stored nowhere except in encrypted form, and is dropped when you lock the wallet or close it. A decrypted key share exists only for the length of one signature.
The keys your phone makes through WebCrypto are non-extractable: code can use them but cannot read them out. The Mini App’s content security policy loads scripts only from our own origin and lets the app connect nowhere else, so no third-party script runs inside the wallet.
Open code, and what it cannot prove
The signing core is public at github.com/paymos-labs/paymos-wallet-core, and it is the source the wallet itself is built from. Anyone can read how the shares are made and combined, and every SDK we publish embeds the same core.
The Mini App around the core is not published, and its code comes from our server each time you open the wallet. Everything on this page describes the code we ship. A compromised server could ship different code, one that reads the phrase after you enter your PIN, and nothing on the phone would notice.
The open core lets you check the signing. It cannot show that the app you were served is the app this page describes. That part of the guarantee rests on our server not being compromised.
Read next
Several wallets in one account, separate on-chain
Hold as many wallets as you need inside one Telegram account. Each is its own account on-chain, and nothing links them until you move money between them, so paying from one tells nobody about the others. Send to a @username or to any address.
Questions
Is the wallet’s code open source?
The signing core is: github.com/paymos-labs/paymos-wallet-core, the Rust library that makes the key shares and signs. The Mini App and the server are not published.
Why is the signing core written in Rust?
The FROST implementation it builds on is written in Rust, and the same code has to run in two places: in a browser engine on your phone and natively on our server. Rust compiles to both WebAssembly and native code from one source.
Which Argon2id settings protect the PIN?
3 passes over 64 MiB of memory, with a random salt that never leaves your phone. Our server only ever checks the Argon2id output, never the PIN itself.
Could your server sign with both shares?
Not with the code we ship. Your share reaches our server only as a backup encrypted with a key made from your recovery phrase, which it cannot open. The limit is the one above: the app’s code comes from our server, so the guarantee holds as long as the code we ship is the code described here.
Where are the keys kept on my phone?
Nowhere in plain form. The phrase is kept encrypted in the Mini App’s browser storage, and the key that opens it is rebuilt from your PIN and our server’s secret each time you unlock.