Wallet Engine is a Rust library for TON wallet applications. It gives Swift, Kotlin, and TypeScript applications the same wallet behavior.
Use Wallet Engine to:
- create and import wallets.
- protect recovery phrases with platform storage.
- parse, validate, and format TON addresses.
- parse
ton://transfer/links. - read balances, enriched activity, and NFT collection metadata.
- load additional history pages.
- resolve
.tonwallet records. - create and decrypt encrypted transfer comments, including comments sent to signing keys that a key rotation replaced.
- rotate the wallet signing key without changing the wallet address.
- sign and submit transfers.
- connect dApps, sign
ton_proof, and approve TON Connect transactions. - observe immutable wallet snapshots.
- cancel active wallet operations.
The engine supports mainnet and testnet. The host application supplies network access and platform security services.
Generate the Swift source and its C module:
just bindings-swiftThen read SWIFT.md for the generated paths and Apple integration requirements.
For a complete SwiftUI integration, see the Swift wallet example. It runs on macOS and iOS and implements the HTTP, Keychain, and journal callbacks required by the engine.
Generate the Kotlin source:
just bindings-kotlinBuild the Android libraries:
just build-androidThen read KOTLIN.md for Android packaging and runtime dependencies.
For a complete Jetpack Compose integration, read the Android wallet example. The example implements the HTTP, Android Keystore, and journal callbacks that the engine requires.
Generate the browser WebAssembly package:
just bindings-wasmThe repository also contains the source of the high-level
@ton/wallet-engine TypeScript package. It provides a browser HTTP host,
IndexedDB journal storage, wallet lifecycle methods, wallet client methods, and
a wallet-side TON Connect runtime.
Read WASM.md for browser setup and security requirements. Read TON_CONNECT.md for the TON Connect API and persistence contract.
For a small React integration, see the web wallet example. It creates a testnet wallet, shows the recovery phrase, refreshes the balance and activity, and loads more history. The engine integration is kept separate from the interface code.
Generate the experimental C++20 wrapper:
just bindings-cppThe output is written to bindings/cpp-experimental. See the
generated C++ Qt example for a small desktop
wallet with balance refresh, activity, transfer preview/send, native HTTP and
platform-host implementations, and build commands.
This repository does not track generated bindings. Generate them from the same revision that you use to build the Rust library.
The pinned binding generators live together under bindgen/:
bindgen/apple— Swift and C UniFFI generation.bindgen/kotlin— Kotlin UniFFI generation.bindgen/wasm— the WebAssembly facade.bindgen/cpp— the UniFFI C++ generator and its own test workspace.
Generated packages remain under bindings/ and are intentionally ignored.
An explicit vMAJOR.MINOR.PATCH tag creates one GitHub Release. SemVer
pre-release tags are also supported.
Each release contains native Linux, macOS, and Windows libraries, a Swift package, an Android AAR, and a TypeScript package with WebAssembly. The release also contains checksums, a machine-readable manifest, and build provenance.
Read RELEASING.md for the file list and the release procedure.
For a native Rust integration, see the Ratatui wallet example. It implements both host interfaces directly in Rust and provides create, import, refresh, history, send, persistence, and delete flows.
This repository does not track generated bindings. Generate them from the same revision that you use to build the Rust library.
Install a current Acton CLI that supports acton simulator start.
cargo nextest run executes unit and scenario tests in parallel. Localnet
scenarios start temporary Acton simulators (acton --project-root <temporary project> simulator start) on free loopback ports and pin blockchain config
param -123 to the Wallet rev00 bytecode in
tests/support/wallet_tg_rev00.code. The scenarios cover wallet deployment,
transfers, refresh, pagination, cancellation, key rotation, and concurrent
chain changes. The TON Connect suite also starts the official Go bridge and a
local TypeScript dApp actor.
If Acton is not in PATH, set the path explicitly:
WALLET_ENGINE_ACTON_BIN=/path/to/acton cargo nextest run --lockedUse just test to also build the generated C++ example and run Rust
documentation tests.
Read the TON Connect test guide for its Go and Node.js setup.
Wallet Engine owns the wallet state machine. Your application implements one provider host and the platform host.
| Interface | Your application provides |
|---|---|
WalletHttpHost |
Bounded HTTP requests and cancellation by request ID |
WalletStatuslessHost |
Body-or-error requests through an opaque relay or protocol proxy |
WalletPlatformHost |
Protected secrets and durable journal storage |
The host callbacks let the same Rust engine run on Apple, Android, and browser platforms. The engine does not own platform networking or protected storage.
Use the normal WalletClient constructor with WalletHttpHost. The HTTP host
must disable redirects and return the observed status, headers, body, and
finalUrl; the engine rejects a finalUrl different from the request URL and
uses HTTP status plus numeric Retry-After for retry classification.
Use WalletClient::new_statusless in Rust, or the generated newStatusless
constructor, when an opaque relay or protocol proxy exposes only the response
body or a transport error. In this mode the request URL is a logical Toncenter
destination rather than proof of a direct origin connection. The host must not
follow or emulate provider redirects, and it does not return a status, headers,
or final URL. The engine can classify only explicit provider codes present in
the body; it does not invent HTTP metadata or Retry-After.
See the TypeScript provider transport example for separate direct HTTP and relay-backed integrations.
TON Connect uses the same wallet hosts. Native applications also own manifest HTTP, bridge HTTP and SSE, protected session storage, and approval screens. Read TON_CONNECT.md for the required delivery order.
- Implement either
WalletHttpHostorWalletStatuslessHost, plusWalletPlatformHost, for your platform. - Create
WalletLifecyclewith your platform host. - Create or import a wallet.
- Store the returned wallet descriptor in your application storage.
- Show the recovery phrase only in the required user flow.
- Create
WalletClientwith the descriptor data and both host objects. - Call
refresh()to load the initial wallet state. - Publish the returned snapshot in your user interface.
- Call
waitForChangeto receive later snapshot revisions. - Call
shutdown()before you discard the client.
The wallet descriptor contains the stable application record ID, address, Ed25519 public key, network, and protected-secret reference. Your application needs these values after a restart. The public key is not a secret.
The engine exports these utilities through Rust, Swift, Kotlin, TypeScript, and
the experimental C++ binding. They do not need a WalletClient or host
callbacks.
Swift, Kotlin, and TypeScript expose parseTonAddress, isValidTonAddress, and
convertTonAddress. Rust and C++ expose parse_ton_address,
is_valid_ton_address, and convert_ton_address.
These functions accept raw and
TEP-2 user-friendly addresses.
Parsing returns the workchain and the user-friendly bounceable and testnet
flags. Conversion returns a canonical raw or user-friendly representation.
User-friendly output uses unpadded URL-safe Base64.
Swift, Kotlin, and TypeScript expose parseTonTransferLink. Rust and C++ expose
parse_ton_transfer_link.
The parser implements the strict ton://transfer/ baseline. It accepts
amount, text, exp, jetton, and bin. The result preserves the recipient,
Gram or jetton asset, exact amount, text or BOC payload, and expiration policy.
The parser validates addresses, decimal values, and single-root BOCs. It does
not resolve chain state, check expiration, select bounce behavior, or authorize
a send. Query parsing follows URI rules, so a literal + remains +.
A bin BOC must pass the same envelope and depth checks as a TON Connect cell:
its header counts must be backed by the input, its references must stay in
range, and it may be at most 1024 cells deep, with every exotic cell laid out
as TON requires.
Swift, Kotlin, and TypeScript expose mnemonicWordlist. Rust and C++ expose
mnemonic_wordlist.
The function returns the 2048 English BIP-39 words in their original index order. It does not filter or reorder the list. Applications can filter the list locally for prefix suggestions.
This export provides the word vocabulary, not another recovery scheme. Wallet import accepts only the Rotation mnemonic scheme described below.
Swift, Kotlin, and TypeScript expose detectMnemonicSchemes. Rust and C++
expose detect_mnemonic_schemes.
The function takes the entered recovery words - one word per element, exactly
as importWallet takes them - and returns every scheme under which they
validate:
| Scheme | Meaning |
|---|---|
rotation |
A 12- or 24-word Rotation mnemonic. The only importable scheme. |
ton |
A passwordless legacy 24-word TON mnemonic (TEP-0003 section 3.1). |
bip39 |
A standard 24-word BIP-39 phrase, the Multichain mnemonic of TEP-0003 section 3.2. |
The checks are independent, and one phrase can validate under more than one scheme, so the result is a list in the fixed order above. An empty list means the words match no scheme the engine knows.
Wallet import succeeds exactly when the result contains rotation. The other
schemes are detection only: the engine derives no keys from them and cannot
import them. Use them to tell the user what they entered - for example, that
the phrase is a TON or plain BIP-39 mnemonic from another wallet scheme -
instead of showing a generic "invalid phrase" error. The wording of that
message belongs to the application.
Detection derives no key material. A password-protected TON mnemonic is not detectable without its password and reports no match.
The engine uses the Rotation mnemonic scheme defined in TEP-0003 section 3.3. A complete phrase contains 24 words, divided into two independent 12-word Multichain mnemonics.
| Half | Words | Key | Role |
|---|---|---|---|
| Anchor | 1–12 | Anchor key | Determines the wallet account address and authorizes the first rotation. Never changes. |
| Signing | 13–24 | Signing key | Signs ordinary outgoing messages. Replaced on rotation. |
Each half is a valid 12-word Multichain mnemonic on its own, BIP-39 checksum included. The halves are generated independently, converted to keys independently, and never joined into a single 24-word mnemonic. The TEP defines the derivation; this document does not repeat it.
This is the only recovery scheme the engine supports. A new wallet starts
before its first key rotation: the signing key equals the anchor key, so
createWallet generates a single 12-word phrase. The first rotation gives the
phrase its second, independent half; later rotations replace only that signing
half. importWallet therefore accepts the phrase exactly as the user recorded
it - 12 words before the first rotation or 24 words after it - and expands the
12-word form internally; applications never duplicate words themselves. TON
mnemonics and plain Multichain mnemonics are rejected as invalid;
detectMnemonicSchemes recognizes them so the application can explain the
rejection.
One 24-word phrase can be valid under more than one scheme. On import an
application should run all the checks it supports — the TON mnemonic checksum,
the Multichain checksum, and the two Rotation checksums over words 1–12 and
13–24 — and should not pick a scheme silently. detectMnemonicSchemes runs
all three checks and reports every match. When more than one validates, tell
the user which schemes matched; this engine derives accounts only for the
Rotation scheme. See
sections 7 and 8.
Rotation support is optional for other wallets. One that skips it reports a Rotation phrase as invalid, which is the expected outcome.
A Rotation mnemonic needs a wallet contract whose address does not depend on the signing key, and which accepts only its stored signing key for ordinary outgoing messages (section 13.1). Wallet V3R1 through V5R1 have no rotatable signing key, and the TEP forbids deploying them from a Rotation mnemonic.
The engine embeds the wallet trampoline: a small code cell that jumps into
the real Wallet rev00 bytecode stored at blockchain config param -123. A
Wallet rev00 revision is present in the testnet config; the mainnet config vote
is in progress. Only the trampoline and the initial storage are deployed per
account, so every wallet shares the config-resident implementation and its
future revisions. The revision is not declared final. The initial StateInit
and address use the anchor public key. Ordinary external and owner-authorized
internal requests use the signing key. Before the first rotation the two keys
are equal. After any rotation a 24-word phrase can sign requests for an already
active account without changing its address.
The engine targets the Wallet rev00 revision whose bytecode cell hash is
e30911420bef1191c09dce58b9df2b4ca4c2d9c383cc3b6a91170349ffa70e2c. The
testnet config holds it at param -123, and the localnet tests install the
same bytecode there.
Words 13–24 of the phrase hold only the current signing key. To keep earlier
signing keys recoverable, every ChangePublicKey request carries a second
reference after the rotation-proof signature:
encryptedOldPrivateKey = sha256(newPrivateKey ‖ "keyChangeSaltV1") XOR oldPrivateKey
Both private keys are 32-byte Ed25519 seeds: oldPrivateKey is the signing key
being replaced, newPrivateKey is the key being installed, and the salt is the
ASCII string keyChangeSaltV1. Only the holder of the new key can open the
value. The reference holds exactly these 256 bits in both
ChangePublicKeyRequestE (0xFBBA99C8, external) and
ChangePublicKeyRequestI (0xFBBA99C7, internal).
After a successful key change the contract emits an external-out log message
with no destination (addr_none): opcode 0xEBA19948 (32 bits) followed by the
256-bit encryptedOldPrivateKey, without references. Toncenter indexer v1.3
and later reports the rotation as a change_wallet_key action whose details
contain new_public_key and encrypted_old_private_key. Starting from the
current key, decryptComment opens each record with the key after it and so
recovers every earlier signing key; see
Encrypted comment decryption.
WalletClient.prepareKeyRotation constructs key-change data. The caller
supplies only an expiration time and the message kind. The client first loads
fresh account state. An active contract uses its seqno getter; a nonexistent
or uninitialized account uses seqno zero and receives anchor-based StateInit
in the prepared message. The client then:
- reads and checks the current protected 12- or 24-word phrase.
- creates an independent 12-word signing half.
- signs the wallet-address proof with the new key.
- encrypts the current signing key with the new key as
encryptedOldPrivateKey. - signs the
ChangePublicKeyrequest, which carries the proof signature andencryptedOldPrivateKeyas its two references, with the current signing key. That key is also the anchor key only before the first rotation. - returns the replacement 24-word phrase, new public key, signed BOC, and the
seqnocovered by that signature.
The method does not change protected storage, submit the BOC by itself, or read the current public key from chain state. The caller must use a protected phrase that matches the current on-chain signing key.
Before submission, store the returned phrase in protected storage, then call
WalletClient.sendBoc with an operation ID plus the returned BOC, seqno, and
expiration. sendBoc validates fresh chain state and the external-message
destination, stores the exact BOC in the same wallet-wide durable journal as
send, and only then hands it to the provider. It returns the normal
SendResult; submissionUnknown, resolvePending, cancelSend, and blocking
of another send use the same workflow as transfers.
The engine rejects deployment from a post-rotation 24-word phrase, for
transfers and for prepareKeyRotation alike: prepareKeyRotation on a
nonexistent or uninitialized account returns KeyRotationUnavailable unless
the protected phrase is the initial 12-word one. The initial contract expects
the anchor key until the on-chain key change is complete.
WalletDescriptor.publicKey holds the anchor public key: it determines the
account address and never changes, so persisted descriptors survive rotation.
The current signing key is on-chain state, not descriptor state.
revealRecoveryPhrase returns the phrase exactly as recorded: 12 words before
rotation or 24 words after it. Protected storage holds one secret.
Key-rotation preparation requests protected-secret access with
SecretAccessReason.prepareKeyRotation. The result contains recovery words,
so applications must treat the complete result as secret data. The engine
adds encryptedOldPrivateKey to the signed BOC itself, so hosts need no extra
step. The value is meant to become public on-chain: only the new signing key
can open it.
The diagrams below cover the wallet lifecycle and transfer flows. Terminal nodes use both a text prefix and a color, so their meaning does not depend on color alone:
CALL ERRORmeans a thrown Swift/Kotlin error or a rejected TypeScript promise.RETURN VALUEmeans the method completed normally, even when its outcome or phase is not successful.SNAPSHOT STATEmeans the method published observable wallet state.- green is successful, red is a call error, blue is snapshot state, and yellow needs explicit handling but is safe to return.
flowchart TD
Lifecycle["WalletLifecycle<br/>create · import · reveal · delete"]
Descriptor["WalletDescriptor<br/>public metadata + secret reference"]
Lifecycle --> Descriptor
Descriptor --> Client["WalletClient"]
Client --> Read["refresh · loadMoreActivity"]
Client --> Preview["previewSend · previewSendBoc"]
Client --> Send["send · sendBoc · prepareKeyRotation"]
Client --> Resolve["resolvePending"]
Client --> Observe["snapshot · waitForChange"]
Read --> Snapshot["WalletSnapshot"]
Send --> Snapshot
Resolve --> Snapshot
Observe --> Snapshot
Client --> Stop["cancel… · shutdown"]
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
class Descriptor,Snapshot result;
Operations that require a running client can return Shutdown or
StateUnavailable. Operations that allocate request IDs or revisions can
return IdentifierExhausted. Provider flows can return
InvalidProviderBaseUrl while constructing a request. These shared guards are
omitted below unless they materially change a flow.
Recovery words exist only across the lifecycle and protected-storage boundary.
They are never stored in WalletDescriptor or WalletSnapshot.
flowchart TD
LifecycleNew["WalletLifecycle.new(platformHost)<br/>RETURN WalletLifecycle"]
LifecycleNew --> Create
LifecycleNew --> Import
LifecycleNew --> Reveal
LifecycleNew --> Delete
Create["createWallet"] --> ValidateCreate{"Valid record ID?"}
ValidateCreate -- no --> InvalidRecord["CALL ERROR<br/>InvalidRecordId"]
ValidateCreate -- yes --> Generate["Generate 12-word Rotation mnemonic"]
Generate --> Generated{"Mnemonic generated?"}
Generated -- no --> InvalidGenerated["CALL ERROR<br/>InvalidRecoveryPhrase"]
Generated -- yes --> DeriveCreate["Derive wallet address and public key"]
Import["importWallet"]
ValidateImport{"Valid record ID<br/>and mnemonic?"}
InvalidImport["CALL ERROR<br/>InvalidRecordId or InvalidRecoveryPhrase"]
Import --> ValidateImport
ValidateImport -- no --> InvalidImport
ValidateImport -- yes --> DeriveCreate
DeriveCreate --> DeriveOk{"Derivation succeeded?"}
DeriveOk -- no --> DeriveError["CALL ERROR<br/>AddressDerivationFailed"]
Store["Host stores mnemonic<br/>with user-presence policy"]
DeriveOk -- yes --> Store
Store --> StoreOk{"Stored?"}
StoreOk -- no --> SecretHost["CALL ERROR<br/>ProtectedSecretHost"]
Created["RETURN VALUE<br/>create: descriptor + one-shot phrase<br/>import: descriptor"]
StoreOk -- yes --> Created
Reveal["revealRecoveryPhrase"]
ValidateDescriptor{"Descriptor identity valid?"}
Reveal --> ValidateDescriptor
ValidateDescriptor -- no --> InvalidDescriptor["CALL ERROR<br/>InvalidRecordId"]
ReadSecret["Host authenticates user<br/>and reads mnemonic"]
ValidateDescriptor -- yes --> ReadSecret
ReadSecret --> ReadOk{"Readable and valid?"}
ReadOk -- host failure --> SecretHost
ReadOk -- invalid mnemonic --> InvalidPhrase["CALL ERROR<br/>InvalidRecoveryPhrase"]
ReadOk -- yes --> RevealDerive{"Address derivation succeeds?"}
RevealDerive -- no --> DeriveError
RevealDerive -- yes --> Match{"Mnemonic derives<br/>descriptor address?"}
Match -- no --> Mismatch["CALL ERROR<br/>SecretWalletMismatch"]
Match -- yes --> Phrase["RETURN VALUE<br/>one-shot RecoveryPhrase"]
Delete["deleteWallet"] --> ValidateDelete{"Descriptor identity valid?"}
ValidateDelete -- no --> InvalidDescriptor
ValidateDelete -- yes --> DeleteSecret["Host deletes protected secret"]
DeleteSecret --> DeleteOk{"Deleted?"}
DeleteOk -- no --> SecretHost
DeleteOk -- yes --> Deleted["RETURN VALUE<br/>success; metadata and journal remain"]
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef callError fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Created,Phrase,Deleted result;
class InvalidRecord,InvalidImport,InvalidGenerated callError;
class DeriveError,SecretHost callError;
class InvalidDescriptor,InvalidPhrase,Mismatch callError;
WalletSnapshot is immutable. Each published change creates a new revision;
observers never receive a mutable view of engine state.
flowchart TD
New["Construct WalletClient"]
SecretRef{"Local secret reference<br/>is absent or nonblank?"}
New --> SecretRef
SecretRef -- no --> BadSecretRef["CALL ERROR<br/>InvalidLocalSecretReference"]
SecretRef -- yes --> PublicKey{"Public key has 32 bytes?"}
PublicKey -- no --> BadKey["CALL ERROR<br/>InvalidWalletPublicKey"]
Initial["RETURN VALUE<br/>WalletSnapshot revision 0<br/>all resources idle"]
PublicKey -- yes --> Initial
Initial --> SnapshotCall["snapshot"]
SnapshotCall --> Clone["RETURN VALUE<br/>immutable snapshot clone"]
Initial --> Wait["waitForChange(afterRevision)"]
StartupResolve["Best-effort resolvePending<br/>failure does not block observation"]
Wait --> StartupResolve
StartupResolve --> Newer{"Current revision is newer?"}
Newer -- yes --> Changed["RETURN VALUE<br/>current snapshot"]
Newer -- no --> Suspend["Suspend without blocking a thread"]
Suspend --> Published["A newer revision is published"]
Published --> Changed
Suspend --> Shut["CALL ERROR<br/>Shutdown"]
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef callError fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Initial,Clone,Changed result;
class BadSecretRef,BadKey,Shut callError;
A public-key-only client has no localSecretRef. It can refresh, paginate,
preview, observe, and resolve. Only send returns LocalSigningUnavailable.
snapshot() remains readable after shutdown; a suspended waitForChange
returns Shutdown.
Account and activity requests run concurrently. The engine waits for both, then
publishes the account and activity resource states separately. An HTTP or
provider failure is stored as ResourceState.error; it does not turn the whole
call into a thrown WalletClientError.
Each nonzero transfer in ActivityItem includes the total transaction fee in
transactionFeeNanograms, an on-chain status (success, failed, or
bounced), and an optional zero-opcode plaintext comment. If one transaction
produces multiple activity rows, its total fee is repeated on every row and
must not be summed per row.
An item with opcode 0x2167da4b instead exposes its complete message-body BOC
as encryptedComment. Decryption is deliberately explicit: pass that BOC and
the sender address to WalletClient.decryptComment. For a received item the
sender is counterparty; for a sent item it is the configured wallet address.
The call asks WalletPlatformHost for the protected mnemonic with
SecretAccessReason.decryptComment, so an ordinary refresh never opens an
authentication prompt. The call can also decrypt comments sent to a signing key
that a rotation replaced; see
Encrypted comment decryption.
flowchart TD
Refresh["refresh"]
Recover["Best-effort resolvePending<br/>failure is ignored here"]
Refresh --> Recover
Recover --> Loading["account = loading<br/>activity = loading"]
Loading --> Parallel["Run both HTTP requests concurrently"]
Parallel --> Joined["Wait for both responses"]
Joined --> Account{"Account response valid?"}
Account -- yes --> AccountReady["SNAPSHOT STATE<br/>account = ready"]
AccountFailed["SNAPSHOT STATE<br/>failed + DomainError<br/>keep old account"]
Account -- no --> AccountFailed
AccountReady --> Activity{"Activity response valid?"}
AccountFailed --> Activity
ActivityReady["SNAPSHOT STATE<br/>merge head page; activity = ready"]
ActivityFailed["SNAPSHOT STATE<br/>failed + DomainError<br/>keep old activity"]
Activity -- yes --> ActivityReady
Activity -- no --> ActivityFailed
ActivityReady --> Count
ActivityFailed --> Count
Count{"Failed resources"}
Count -- 0 --> Complete["RETURN VALUE<br/>WalletUpdate: Completed"]
Count -- 1 --> Partial["RETURN VALUE<br/>WalletUpdate: PartiallyCompleted"]
Count -- 2 --> Failed["RETURN VALUE<br/>WalletUpdate: Failed"]
Refresh --> NewRefresh["A newer refresh starts"]
Superseded["RETURN VALUE<br/>old refresh: Superseded"]
NewRefresh --> Superseded
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef state fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;
classDef soft fill:#fef3c7,stroke:#b45309,color:#78350f;
class Complete result;
class AccountReady,ActivityReady,AccountFailed,ActivityFailed state;
class Partial,Failed,Superseded soft;
DomainError classifies transport, provider protocol, rate limit,
cancellation, and host-policy failures and includes retry advice.
A sender encrypts a comment to the key that this wallet's get_public_key
getter returns at that time, which is the current signing key. After a
rotation, older comments can therefore use a key that the recovery phrase no
longer holds. decryptComment tries every signing key it can recover:
- It reads the protected phrase once per call and derives the current signing key (words 13–24) and the anchor key (words 1–12). It tries the current signing key, then the anchor key, without any HTTP request. Comments that this engine sent use the signing key current at that time on the sender side, so those sent before a later rotation are recovered like received ones.
- When neither key matches and the wallet has rotated, that is, the two keys
differ, it reads the wallet's
change_wallet_keyactions from Toncenter v3:GET {toncenterBaseUrl}/api/v3/actions?account=<wallet address>&action_type=change_wallet_key&limit=100&offset=<n>&sort=desc, newest first, reading at most 10 pages. It keeps only successful key changes of this wallet. - Starting from the current key, it opens the encrypted old key of the rotation that installed it, then repeats with the opened key until it reaches the anchor key, and tries the recovered keys.
An unrotated wallet never requests the history. The history holds only public
data: installed public keys and old keys encrypted with their successors. The
client caches it in memory and reuses it while it still contains the rotation
that installed the current signing key, so later calls normally send no HTTP
request. Recovered private keys exist only inside the call. The engine never
returns or stores them and clears them before the call returns. While the call
runs, it holds the client's single-flight slot, so send, sendBoc, and other
slot users return SendAlreadyInProgress until it finishes.
EncryptedCommentLookupFailed means that the history is not available yet:
the provider failed, limited, or cancelled a request, returned an unreadable
page, or reported a history that does not yet include the rotation that
installed the current signing key because the indexer lags behind the chain.
Retry later. EncryptedCommentUnavailable means that no key of this wallet
decrypts the body, or that the body is malformed.
Comments sent to a replaced key need a provider that runs Toncenter indexer
v1.3 or later, which reports change_wallet_key actions with both keys.
flowchart TD
More["loadMoreActivity"]
Allowed{"No refresh/page load active,<br/>hasMore and cursor exist?"}
More --> Allowed
Skipped["RETURN VALUE<br/>WalletUpdate: Skipped<br/>no HTTP request"]
Allowed -- no --> Skipped
Allowed -- yes --> Page["Request next older page"]
Page --> Response{"Response"}
Response -- valid --> Advance{"Cursor moves to older LT?"}
Advance -- yes --> Merge["Merge by item ID<br/>sort by descending LT"]
Completed["RETURN VALUE<br/>WalletUpdate: Completed<br/>itemsAdded = new rows"]
Merge --> Completed
StopPages["RETURN VALUE<br/>Completed; 0 added; hasMore = false"]
Advance -- no --> StopPages
Cancelled["RETURN VALUE: Cancelled<br/>SNAPSHOT STATE: pagination idle"]
Failed["RETURN VALUE: Failed<br/>SNAPSHOT STATE: DomainError"]
Response -- host cancelled --> Cancelled
Response -- other error --> Failed
More --> RefreshWins["A refresh starts"]
RefreshWins --> Superseded["RETURN VALUE<br/>page call: Superseded"]
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef soft fill:#fef3c7,stroke:#b45309,color:#78350f;
classDef error fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Merge,Completed result;
class Skipped,StopPages,Cancelled,Failed soft;
class Superseded soft;
Preview uses fresh chain state and a fake signature. It never reads the journal
or recovery phrase, and its BOC is never reused by send.
flowchart TD
Preview["previewSend"] --> Busy{"Send or preview already active?"}
Busy -- send --> SendBusy["CALL ERROR<br/>SendAlreadyInProgress"]
Busy -- preview --> PreviewBusy["CALL ERROR<br/>SendPreviewAlreadyInProgress"]
Busy -- no --> Account["Load fresh account state"]
Account --> AccountOk{"Valid response?"}
AccountOk -- no --> PreviewFailed["CALL ERROR<br/>SendPreviewFailed"]
AccountOk -- yes --> Balance{"Exact amount <= balance?"}
Balance -- no --> NoBalance["CALL ERROR<br/>InsufficientBalance"]
Balance -- yes --> Status{"Account status"}
Status -- active --> Seqno["Load fresh seqno"]
Status -- nonexistent / uninitialized --> Zero["Use seqno 0 + StateInit"]
Status -- frozen / unknown --> Unavailable["CALL ERROR<br/>SendAccountUnavailable"]
Seqno --> SeqnoOk{"Valid seqno response?"}
SeqnoOk -- no --> PreviewFailed
SeqnoOk -- yes --> Build
Zero --> Build["Calculate validUntil<br/>build fake-signed wallet BOC"]
Build --> Prepared{"Preview BOC prepared?"}
Prepared -- time overflow --> PreviewFailed
Prepared -- BOC build failed --> PreviewFailed
Prepared -- yes --> Emulate["Toncenter emulation<br/>ignore_chksig"]
Emulate --> EmulationResult{"Emulation result"}
EmulationFailed["CALL ERROR<br/>EmulationFailed"]
NotAccepted["CALL ERROR<br/>EmulationMessageNotAccepted"]
Rejected["CALL ERROR<br/>EmulationRejected<br/>includes TVM phase codes"]
Fees{"Exact amount + wallet fee<br/>fits with positive remainder?"}
EmulationResult -- transport / schema failure --> EmulationFailed
EmulationResult -- message not accepted --> NotAccepted
EmulationResult -- wallet transaction failed --> Rejected
EmulationResult -- wallet succeeded --> Fees
Fees -- no --> FeeBalance["CALL ERROR<br/>InsufficientBalanceForFees"]
Result["RETURN VALUE<br/>SendPreview<br/>fees · actions · warnings · BOC"]
Fees -- yes --> Result
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef callError fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Result result;
class SendBusy,PreviewBusy,PreviewFailed,NoBalance,Unavailable callError;
class EmulationFailed,NotAccepted,Rejected,FeeBalance callError;
Child transaction failures can make traceSucceeded false without rejecting
the preview. Only failure of the source wallet transaction produces
EmulationRejected.
The durable journal write is the critical boundary. Before it, cancellation is safe. After it starts, the exact signed BOC may survive a crash or reach the provider, so an uncertain result must remain unresolved.
flowchart TD
Send["send"] --> Signer{"Local signing configured?"}
Signer -- no --> NoSigner["CALL ERROR<br/>LocalSigningUnavailable"]
Signer -- yes --> Busy{"Send or resolver active?"}
Busy -- yes --> Already["CALL ERROR<br/>SendAlreadyInProgress"]
Busy -- no --> Journal["Load durable wallet send slot"]
Journal --> JournalOk{"Journal valid and readable?"}
JournalOk -- no --> PreError["CALL ERROR<br/>SendFailed<br/>SNAPSHOT STATE: Failed"]
JournalOk -- yes --> Account["Load fresh account + provider time"]
Account --> AccountOk{"Fresh account response valid?"}
AccountOk -- no --> PreError
AccountOk -- yes --> Prior{"Unresolved signed BOC exists?"}
Prior -- yes --> Force{"request.force?"}
Force -- no --> Resolve["Run pending-resolution flow"]
Force -- yes --> Checks
Blocked["CALL ERROR<br/>PreviousSubmissionUnresolved<br/>do not replace"]
Resolve -- still pending --> Blocked
Resolve -- provider / journal failure --> PreError
Resolve -- terminal --> Checks
Prior -- no --> Checks{"Balance, status, seqno,<br/>validUntil checks pass?"}
Typed["CALL ERROR<br/>InsufficientBalance, account unavailable,<br/>or SendFailed"]
Unlock["Host authenticates user<br/>and reads protected mnemonic"]
Checks -- no --> Typed
Checks -- yes --> Unlock
Unlock --> Secret{"Mnemonic valid and<br/>matches wallet address?"}
Secret -- no --> SecretError["CALL ERROR<br/>InvalidProtectedSecret or SendFailed"]
Sign["Build and sign fresh wallet BOC<br/>zeroize Rust secret buffer"]
Signed{"Signed BOC built?"}
Boundary["DURABLE COMMIT BOUNDARY<br/>CAS signed BOC into journal"]
Secret -- yes --> Sign
Sign --> Signed
Signed -- no --> PreError
Signed -- yes --> Boundary
Boundary --> Persisted{"Prepared journal CAS result"}
UnknownError["CALL ERROR<br/>SubmissionUnknown<br/>SNAPSHOT STATE: SubmissionUnknown"]
JournalConflict["CALL ERROR<br/>SendAlreadyInProgress<br/>SNAPSHOT STATE: Failed"]
Persisted -- host error --> UnknownError
Persisted -- CAS conflict --> JournalConflict
Persisted -- applied --> Submit["Submit the exact journaled BOC"]
Submit --> Provider{"Definite provider result?"}
Accepted["Provider accepted BOC"]
Rejected["Provider explicitly rejected BOC"]
Unknown["Provider outcome is ambiguous"]
Provider -- accepted --> Accepted
Provider -- explicit rejection --> Rejected
Provider -- ambiguous response --> Unknown
Accepted --> TerminalPersist{"Terminal CAS succeeds?"}
Rejected --> TerminalPersist
Unknown --> TerminalPersist
TerminalPersist -- no --> UnknownError
TerminalPersist -- accepted persisted --> SubmittedResult
TerminalPersist -- rejection persisted --> FailedResult
TerminalPersist -- uncertainty persisted --> UnknownResult
SubmittedResult["RETURN VALUE<br/>phase = Submitted<br/>not confirmed on-chain"]
FailedResult["RETURN VALUE: phase = Failed<br/>SNAPSHOT STATE: Failed"]
UnknownResult["RETURN VALUE: SubmissionUnknown<br/>SNAPSHOT STATE: SubmissionUnknown"]
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef soft fill:#fef3c7,stroke:#b45309,color:#78350f;
classDef callError fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Boundary soft;
class SubmittedResult result;
class FailedResult,UnknownResult soft;
class NoSigner,Already,PreError,Blocked,Typed callError;
class SecretError,UnknownError,JournalConflict callError;
An explicit provider rejection becomes a definite SendResult with phase
failed only after the terminal journal CAS succeeds. A transport failure
after submission is not definite: it produces submissionUnknown, and the
application must resolve it before signing another payment by default.
Set SendRequest.force only after the user explicitly confirms a replacement.
It allows a new signature while the previous signed BoC remains unresolved.
The previous transfer can still execute, so both transfers can affect the
wallet balance. SendSnapshot.resolution.canForceRetry reports whether the
current durable send can be overridden this way.
resolvePending never unlocks the mnemonic and never signs. It evaluates
evidence from strongest to weakest so a confirmed message is not mistaken for
a replacement merely because both advance the wallet seqno.
flowchart TD
Resolve["resolvePending"] --> Busy{"Send or resolver active?"}
Busy -- yes --> Already["CALL ERROR<br/>SendAlreadyInProgress"]
Busy -- no --> Journal["Load durable send journal"]
Journal --> Exists{"Journal state"}
Exists -- absent --> Current["RETURN VALUE<br/>current SendSnapshot"]
Exists -- already terminal --> Terminal["RETURN VALUE<br/>terminal SendSnapshot"]
Exists -- invalid / host failure --> Failed["CALL ERROR<br/>SendFailed"]
Exists -- pending --> Time["Load provider synchronization time"]
Time -- provider failure --> Failed
Time --> Executed{"Transaction found by<br/>inbound message hash?"}
Executed -- provider failure --> Failed
Executed -- yes --> Confirmed["Confirmed"]
Executed -- no --> Mempool{"Message in pending set?"}
Mempool -- yes --> InMempool["Still pending: InMempool"]
Mempool -- absent or 404 / 405 --> SeqnoObserved
Mempool -- other endpoint error --> SeqnoUnknown
SeqnoObserved["Load wallet seqno<br/>pending absence observed"]
SeqnoUnknown["Load wallet seqno<br/>expiration proof unavailable"]
SeqnoObserved -- provider failure --> Failed
SeqnoUnknown -- provider failure --> Failed
SeqnoObserved --> AdvancedObserved{"Indexed seqno > signed seqno?"}
SeqnoUnknown --> AdvancedUnknown{"Indexed seqno > signed seqno?"}
Recheck{"Recheck transaction by message<br/>to close index snapshot race"}
AdvancedObserved -- yes --> Recheck
AdvancedUnknown -- yes --> Recheck
Recheck -- provider failure --> Failed
Recheck -- found --> Confirmed
Recheck -- absent --> Replaced["Replaced"]
Expired{"Pending absence observed and<br/>time > validUntil + margin?"}
AdvancedObserved -- no --> Expired
AdvancedUnknown -- no --> Awaiting
Expired -- yes --> ExpiredResult["Expired"]
Expired -- no --> Awaiting["Still pending: AwaitingWindow"]
InMempool --> PendingReturn
Awaiting --> PendingReturn
PendingReturn["RETURN VALUE + SNAPSHOT STATE<br/>still unresolved; do not replace"]
Confirmed --> Persist["CAS terminal evidence to journal"]
Replaced --> Persist
ExpiredResult --> Persist
Persist --> Cas{"CAS result"}
Cas -- applied --> Publish["RETURN VALUE + SNAPSHOT STATE<br/>terminal SendSnapshot"]
Cas -- same resolution already persisted --> Publish
Cas -- same pending record changed --> Retry["Retry CAS up to 3 times"]
Retry --> Persist
Cas -- invalid / disappeared / contended --> Failed
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef soft fill:#fef3c7,stroke:#b45309,color:#78350f;
classDef callError fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Current,Terminal,Confirmed,Publish result;
class Replaced,ExpiredResult,InMempool,Awaiting soft;
class PendingReturn,Retry soft;
class Already,Failed callError;
refresh and waitForChange make a best-effort attempt to recover a durable
pending send and ignore resolver failure. A later send runs the same evidence
algorithm but reports failure and refuses to sign while the old message remains
unresolved.
flowchart TD
Shutdown["shutdown"] --> DoneAlready{"Already shut down?"}
DoneAlready -- yes --> Done["RETURN VALUE<br/>success"]
Closing["New running-only calls<br/>CALL ERROR: Shutdown"]
DoneAlready -- no --> Closing
Closing --> Committing{"A send crossed the<br/>durable commit boundary?"}
Wait["Wait for active send to finish<br/>possibly as SubmissionUnknown"]
Committing -- yes --> Wait
Wait --> Committing
CancelAll["Cancel reads, preview, resolver,<br/>and reversible send"]
States["Loading resources become idle<br/>reversible send becomes cancelled"]
Committing -- no --> CancelAll
CancelAll --> States
States --> Wake["Release waitForChange callers<br/>with CALL ERROR: Shutdown"]
Wake --> Done
CancelApi["cancelRefresh<br/>cancelLoadMoreActivity<br/>cancelSendPreview"]
CancelHttp["RETURN VALUE<br/>success; cancel tracked HTTP<br/>no-op when idle"]
CancelApi --> CancelHttp
CancelHttp --> ReadResult["Active refresh/page<br/>RETURN VALUE: Superseded"]
CancelHttp --> PreviewResult["Active preview<br/>CALL ERROR: StateUnavailable"]
CancelSend["cancelSend"] --> Active{"Send active?"}
Active -- no --> CancelNoop["RETURN VALUE<br/>success; no state change"]
Active -- yes --> Boundary{"Before durable boundary?"}
SendCancelled["RETURN VALUE: cancel success<br/>SNAPSHOT STATE: Cancelled"]
Boundary -- yes --> SendCancelled
SendCancelled --> SendResult["Active send<br/>CALL ERROR: StateUnavailable"]
Boundary -- no --> TooLate["CALL ERROR<br/>SendCancellationTooLate"]
classDef result fill:#dcfce7,stroke:#15803d,color:#14532d;
classDef soft fill:#fef3c7,stroke:#b45309,color:#78350f;
classDef callError fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d;
class Done result;
class Wait,CancelHttp,ReadResult,CancelNoop,SendCancelled soft;
class PreviewResult,SendResult,TooLate callError;
WalletSnapshot is the only state that the user interface needs. It contains:
- the account balance and status.
- recent activity and its pagination cursor.
- owned NFTs and their resolved collection descriptors.
- independent states for account, activity, and pagination resources.
- the latest send state.
- a monotonic revision number.
A refresh can complete partially. Read each resource state before you replace previous data or show an error.
When an NFT belongs to a collection, NftItem.collection contains a neutral
NftCollectionDescriptor with the verified collection address, standard
TEP-64 name, description, and image fields, plus all string-valued
collection metadata. Product-specific kinds such as Telegram gifts, usernames,
or numbers remain application-side classifications.
Use waitForChange(afterRevision:) on Swift. Use
waitForChange(afterRevision) on Kotlin. Both calls wait until a newer snapshot
is available.
Call WalletClient.resolveDns with a .ton name before building a send intent.
The engine validates and lowercases the name, follows provider-side DNS
delegation, and selects the standard wallet category. It returns a
network-correct TonAddressString, or None/nil/null when the name has no
wallet record. It does not read the protected mnemonic.
ProviderConfig.dnsRootAddress is an optional resolver override. When it is
absent, the engine uses the current root address for the wallet network that was
snapshotted from blockchain config #4. This avoids an extra provider request
per lookup while allowing an application to update the resolver immediately.
DNS resolution is explicit and does not rewrite SendIntent destinations.
Malformed names, provider failures, or invalid provider records return the
typed DnsResolutionUnavailable error. The current API resolves ASCII .ton
names and wallet records only; product-specific naming and presentation stay
in the host. See the TON DNS documentation
and TEP-81.
Call previewSend to show fees, actions, and execution warnings before the
user confirms a transfer. This call does not read the recovery phrase.
The preview is information, not permission to send. A preview failure does not
block send.
Chain state can change after the preview. Therefore, send always rebuilds the
wallet message from fresh account state. engineDefault also resolves a new
expiration from provider time. exact preserves the timestamp in the intent.
Call send after user confirmation. Give it a unique operation ID and the
same immutable SendIntent that the user approved.
SendIntent contains one expiration policy and an ordered batch of 1 to 255
SendMessage values. Each message contains its destination, amount, body, and
optional destination StateInit. A body is empty, a plaintext comment, or one
caller-built payload cell.
For an encrypted transfer comment, call createEncryptedComment before
preview. The engine always reads the recipient account state. For an active
wallet it calls get_public_key and ignores any supplied key, because the
contract reports its current key while a key derived from the address is only
its initial one. For a nonexistent or uninitialized account it uses the
optional 32-byte Ed25519 recipientPublicKey: the engine locally reconstructs
supported default wallet StateInit values from the key and requires one to
derive the recipient address. Without that key, or when it does not match, an
undeployed recipient cannot receive an encrypted comment. Any rejection happens
before protected-secret access. It authorizes
the protected sender mnemonic through the platform host, applies the TON
Ed25519/X25519, HMAC-SHA512, AES-256-CBC, and snake-cell format with the
wallet's current signing key as the sender key, and returns a complete BOC.
That is the key the wallet's get_public_key reports. Put that BOC in SendMessageBody.rawPayload, then preview and
send the same immutable intent. Plaintext is limited to 960 UTF-8 bytes.
See the TON encrypted-comments
format.
Supplied-key verification supports Wallet V1/V2, V3/V4 with default wallet IDs
(including workchain-aware defaults), V5R1 with a network- and workchain-aware
default ID, and the engine's Wallet rev00 with its default ID in workchain 0.
Custom wallet IDs and unsupported initial parameters are rejected. This local
verification applies only when recipientPublicKey is supplied for an
undeployed recipient.
To learn whether a comment for a recipient can be encrypted before any secret
is involved, call resolveEncryptedCommentRecipient with the same recipient
and optional recipientPublicKey. It resolves the key exactly as
createEncryptedComment does and returns the 32 bytes that call would encrypt
for, but it never requests a protected secret, so a client without a local
signing secret can call it too. It reads the account state and takes the
single-flight slot even when a key is supplied.
EncryptedCommentUnavailable means the recipient cannot receive an encrypted
comment: its wallet is frozen, its contract returned no public key, or its
wallet is not deployed and no supplied key derives its address.
EncryptedCommentLookupFailed means the provider failed, limited, or cancelled
the lookup, so nothing is known about the recipient and the call can be
retried. createEncryptedComment returns the same two errors, before it
requests the secret. decryptComment uses the same two errors for its
key-change history; see
Encrypted comment decryption.
SendAmount.all must be the only message in its batch. Wallet V5 applies the
batch in order.
Use engineDefault expiration for a normal wallet transfer. Use exact only
when a trusted caller supplies a Unix expiration timestamp. TON Connect uses
exact to preserve the dApp valid_until value.
Set sendValiditySeconds in WalletClientConfig according to your product's
submission policy. The engine adds this duration to the synchronization time
from the fresh Toncenter account response. It does not trust the device clock.
A short duration can expire before inclusion. A long duration leaves the
signed message valid for longer.
previewSend does these steps:
- loads a fresh account state and sequence number.
- builds the complete wallet intent from the stored public key.
- emulates it with a placeholder signature and
ignore_chksig. - returns fees, actions, trace status, and the preview expiration time.
previewTonConnect accepts the complete TON Connect SendRequest. It keeps the
dApp expiration, message order, payloads, and destination StateInit values.
previewSendBoc accepts an already signed external-message BOC, validates its
destination and the supplied fresh seqno and expiration, and emulates the
exact bytes without journaling or submitting them. The returned message list is
empty because the signed BOC remains opaque; messageBocBase64 is the exact
input BOC. Preview ignores the operation ID and force, so the same request can
be passed to sendBoc after approval.
previewSignMessage validates a sign-only intent from fresh public state. It
does not report wallet-paid fees because a relayer supplies the inbound TON.
signMessage signs and journals a Wallet V5 internal_signed request. It
returns a complete relaxed internal-message BoC with the handedOff phase. It
does not submit the message.
send starts a new workflow after confirmation:
- loads the durable send journal.
- loads a new account state and sequence number.
- resolves the intent expiration policy.
- requests the protected recovery phrase from the host.
- makes sure that the phrase belongs to the selected wallet.
- builds and signs a new wallet message in Rust.
- stores the exact signed BoC in the host journal.
- submits that BoC to the provider.
sendBoc enters the same workflow with an already signed external message. It
skips secret authorization and signing, but still reloads account state,
requires the supplied seqno and expiration to be current, validates the BOC
destination, persists the exact bytes before handoff, and publishes the same
send phases and resolution state.
SendRequest.force and SendBocRequest.force default to false at JSON
boundaries. When true, they can replace an unresolved durable signed BoC
without first obtaining terminal provider evidence. Use this only after
showing the unresolved operation and receiving explicit user confirmation.
SendResult.signedBoc contains the exact signed external-message BoC. TON
Connect returns this value to the dApp after an accepted or uncertain
submission. Do not log it.
The emulation request does not contain the mnemonic or private key. Child
transaction failures remain in SendEmulation.traceSucceeded; they do not
automatically block submission because a recipient can reject or bounce.
SendEmulation.actions contains the high-level actions returned by Toncenter.
Each action has validated Base64 identifiers, involved accounts, and its
action-specific details as JSON.
SendPreviewFailed means fresh state could not be loaded or the fake-signed
message could not be built. EmulationFailed means that the emulation service,
transport, or response failed. EmulationMessageNotAccepted means that the
current chain state rejected the external message, for example after another
client advanced the wallet seqno. EmulationRejected means that the external
message created a wallet transaction, but its compute or action phase failed.
The last error includes the returned TVM phase codes.
Handle every send phase explicitly:
| Phase | Meaning |
|---|---|
submitted |
Provider accepted the BoC; it is not confirmed on-chain. |
failed |
Provider explicitly rejected the request. |
submissionUnknown |
The BoC can be accepted, but the result is unknown. |
cancelled |
The operation stopped before the durable submission boundary. |
handedOff |
The signed internal BoC is durable and available to a relayer. |
sequenceNumberConsumed |
The reserved sequence number changed, but the exact relayer transaction is unknown. |
CAUTION: If the phase is submissionUnknown, do not create and submit a new
transfer automatically. The first signed message can already be in the
network.
The journal uses compare-and-swap writes. The host implementation must make these writes durable before it reports success.
Wallet Engine implements wallet-side TON Connect v2 sessions for native and
browser applications. It handles link parsing, authenticated encryption,
replay protection, raw transaction batches, gasless signMessage, proof
signing, and restart-safe responses.
The host shows every approval and owns protected session storage. Native hosts
also own manifest and bridge transport. The browser package provides this
transport through TonConnectWallet.
Read TON_CONNECT.md for supported requests, send mapping, session recovery, and the durable bridge-response sequence. See the Swift, web, and terminal examples for complete integrations.
WalletClient does not contain a chain streaming API. The host application
owns its chain stream, reconnect policy, and application lifecycle.
Native TON Connect sessions parse SSE chunks but do not open the connection.
The native host owns the bridge stream and its reconnect policy. The browser
TonConnectWallet includes this transport.
The engine also does not reconcile stream events. A host can start a normal refresh when its product behavior requires new wallet data.
- Store recovery phrases with the platform protected-storage API.
- Require user authentication according to your product policy.
- Do not log recovery phrases, secret values, or signed BoCs.
- Add a Toncenter credential only when the request origin matches the configured Toncenter base URL.
- Enforce the request and response limits from each
HttpRequest. - Honor
cancelHttpfor requests that have not completed. - Store wallet descriptors separately from protected recovery phrases.
- Store serialized TON Connect sessions as protected authentication material.
- Persist a pending TON Connect response before its bridge POST starts.
The engine clears the secret buffers that it owns. The FFI boundary and the host language can create additional copies.
Earlier signing keys that decryptComment recovers are secret material with
the same protection as the recovery phrase: each one decrypts the comments
sent to it. They never cross the FFI boundary; the engine keeps them only for
the call and clears them. Anyone who learns a signing key can open every
earlier published key from the public key-change history, so a leaked phrase
also exposes the comments sent to those earlier keys. The history itself
holds only public data: installed public keys and old keys encrypted with
their successors. The engine caches it in memory without protection, and the
host transports it as an ordinary provider response.
The host must limit the lifetime of these copies. The host must clear mutable byte buffers after each callback. Immutable client strings cannot be reliably cleared, so release them immediately after the recovery screen closes.
See Swift, Kotlin, and WebAssembly for the client-specific rules.
The repository pins Rust 1.96.1 in rust-toolchain.toml.
Build the release library:
just buildRun all repository checks:
just checkInstall the optional repository tools before the first complete check:
just install-toolsRun the bounded Kani proofs for the root Rust crate:
just kani-setup
just kaniUse just kani-list to list the available proof harnesses. Kani 0.67 bundles
Rust 1.93, so verification/kani/Cargo.toml points a verification-only package
at the production src/lib.rs. This keeps the production Rust 1.96.1
requirement intact and verifies the same source code.
Run cargo xtask --help to see the binding and Android build commands.
Wallet Engine is available under the Apache License 2.0 or the MIT License. See LICENSE-APACHE and LICENSE-MIT.
