ZaStoGram_desktop/Telegram/SourceFiles/wallet/wallet_engine.h
John Preston f5d280d2d6 Send owned collectibles through the wallet send core
Task: 2026/10/07/send-owned-collectibles-through-the-wallet-send-core
2026-10-08 22:24:50 +04:00

440 lines
15 KiB
C++

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "base/weak_ptr.h"
#include <memory>
#include <optional>
#include <vector>
namespace wallet_engine {
struct SendResult;
struct WalletClient;
struct WalletClientConfig;
struct WalletLifecycle;
} // namespace wallet_engine
namespace Main {
class Session;
} // namespace Main
namespace Wallet {
class Api;
class VaultRuntime;
// Why the platform host refused a protected-secret read. Missing is the one
// answer a caller may act on irreversibly - the value is not in storage at
// all - while Unreadable covers every shape of "it is there and this read
// did not produce it", which a locked keyring, a corrupt record and a
// momentary host error all reach alike.
enum class SecretReadFailure : uchar {
None,
Locked,
Missing,
Unreadable,
};
struct EngineError {
QString message;
// The engine's typed error when one produced this failure. Rethrow it
// to dispatch on the generated wallet_engine error taxonomy.
std::exception_ptr underlying;
// What the host answered to a protected-secret read this job made, as
// the watch around the job recorded it. The engine rewrites such a
// refusal into its own client taxonomy - a send answers SendFailed -
// so by the time `underlying` is inspected the read's own verdict is
// no longer in it, and this field is what survives the rewrite.
SecretReadFailure secret = SecretReadFailure::None;
};
// The protected-secret storage keys one engine call caused this
// application to write, so a call that fails after its own store can
// remove exactly what it wrote. The key is REMEMBERED here, never
// derived: PlatformHost::store_protected_secret computes the account
// storage key itself before it marshals the write, and the engine's
// reference format is opaque to this client.
//
// Attribution: a store belongs to a call if and only if it is committed
// on the thread that opened that call's recording, while it is open.
// Engine::run() runs jobs one at a time on one worker and runQuick()'s
// contract forbids storage, so no other operation of this Engine can
// store into an open recording; the binding to the opening thread is
// what keeps another account's Engine out of it too. A store on any
// other thread is not recorded, which leaves the record behind exactly
// as today - the safe direction, because deleting another operation's
// record is worse than the orphan.
//
// Lifetime: the handle is created before the call and owned by that
// call's continuations, so a successful call simply drops it and no
// record is ever eligible for a later drop.
class EngineSecretStores final {
public:
class Recording final {
public:
explicit Recording(not_null<EngineSecretStores*> stores);
Recording(const Recording &other) = delete;
Recording &operator=(const Recording &other) = delete;
~Recording();
private:
EngineSecretStores *_previous = nullptr;
};
// Worker thread, inside the job, around the one engine call whose
// stores are being attributed.
[[nodiscard]] Recording record();
// Worker thread. Called by the platform host once a store reached
// disk. Does nothing when no recording is open on this thread.
static void Remember(const QString &storageKey);
// Main thread. The keys recorded so far, leaving none behind.
[[nodiscard]] std::vector<QString> take();
private:
std::vector<QString> _keys;
};
// Whether the platform host refused a protected-secret read during the
// engine call running on this thread. The engine folds such a refusal into
// the same typed failure as a decryption with the wrong key, so a caller
// that must tell the two apart opens a watch around its call and reads it
// afterwards. Watches nest: a refusal is recorded by every watch open on
// the thread, so the one Engine::Execute holds around each job sees what an
// inner watch saw. Same thread contract as the store recording above.
class SecretReadWatch final {
public:
SecretReadWatch();
SecretReadWatch(const SecretReadWatch &other) = delete;
SecretReadWatch &operator=(const SecretReadWatch &other) = delete;
~SecretReadWatch();
[[nodiscard]] SecretReadFailure failure() const;
// Worker thread. Called by the platform host when a read is refused.
// Does nothing when no watch is open on this thread.
static void MarkFailed(SecretReadFailure failure);
private:
SecretReadWatch *_previous = nullptr;
SecretReadFailure _failure = SecretReadFailure::None;
};
// The same verdict the watch records, for a caller that holds the engine's
// typed error instead. Answers None for every failure that is not the host
// refusing a protected secret.
[[nodiscard]] SecretReadFailure ProtectedSecretFailure(
const EngineError &error);
enum class TransferSubmissionOutcome {
Accepted,
Rejected,
Uncertain,
};
struct TransferSubmissionAnswer {
TransferSubmissionOutcome outcome = TransferSubmissionOutcome::Uncertain;
QString diagnostic;
};
struct TransferSubmissionData {
QByteArray normal;
std::optional<QByteArray> gasless;
};
// The send whose one sendBoc submission the status-less host routes through
// Telegram's wallet.sendTransfer instead of the toncenter proxy. The host
// decodes the signed BOC out of the engine's request, pairs it with the
// relayer alternative the recording carries, hands both to the main thread
// through submit(), and answers the engine with a provider-shaped body
// (accepted / definitely rejected) or a host error (uncertain), so the
// engine's journal, phases and resolution stay authoritative.
//
// A gasless-eligible transfer signs both delivery forms in one engine
// prepare_transfer() and submits the external one through send_boc(), so the
// alternative the server may pick instead never travels inside the engine's
// own request. The recording carries it beside the routed call, which is why
// only the normal BOC is decoded out of the request here.
//
// Attribution: a submission belongs to the send whose recording is open on the
// calling thread. Engine::run() runs jobs one at a time on one worker and
// runQuick()'s contract forbids network, so no other engine call of this
// Engine can be routed while a recording is open; the rotation's send_boc
// opens none and keeps the proxy transport. A recording wraps exactly one
// client->send, client->send_boc or client->send_nft_transfer call.
//
// Lifetime: Current() hands the host a copy of the thread-local, and the
// host's queued main-thread work holds that reference itself, so a job that
// times out, throws and drops its own capture cannot leave the queued work
// dangling.
class TransferSubmission final
: public std::enable_shared_from_this<TransferSubmission> {
public:
using Submit = Fn<void(
TransferSubmissionData data,
Fn<void(TransferSubmissionAnswer)> done)>;
explicit TransferSubmission(Submit submit);
class Recording final {
public:
Recording(
std::shared_ptr<TransferSubmission> submission,
std::optional<QByteArray> gasless);
Recording(const Recording &other) = delete;
Recording &operator=(const Recording &other) = delete;
~Recording();
private:
std::shared_ptr<TransferSubmission> _previous;
std::optional<QByteArray> _previousGasless;
};
// Worker thread, inside the job, around the one send call whose
// submission is being routed. `gasless` is the relayer alternative
// prepared for the same seqno and validity window, absent for a send
// with no fee-free offer.
[[nodiscard]] Recording record(
std::optional<QByteArray> gasless = std::nullopt);
// Worker thread. The submission recorded on this thread, empty when no
// recording is open.
[[nodiscard]] static std::shared_ptr<TransferSubmission> Current();
// Worker thread. The relayer alternative of the open recording.
[[nodiscard]] static std::optional<QByteArray> CurrentGasless();
// Main thread. Answers through `done` exactly once, on the main thread.
void submit(
TransferSubmissionData data,
Fn<void(TransferSubmissionAnswer)> done);
private:
const Submit _submit;
};
// Owns the wallet-engine host implementations and the workers running
// blocking engine calls. Local secret reads use their own serial worker so
// background network requests cannot delay them. The engine translates
// nothing by itself: HTTP goes through the caller-owned Wallet::Api on the main thread,
// secrets and the send journal go through Storage::Account on the main
// thread. Network operations and storage writes run through run(); only
// local read operations may run through runLocal().
// Each secret has its own K in the domain keyring. Engines borrow the shared
// VaultRuntime, which owns D, grants and retention for every signed-in account.
// Every Engine method must be called on the main thread.
class Engine final : public base::has_weak_ptr {
public:
Engine(not_null<Main::Session*> session, not_null<Api*> api);
~Engine();
[[nodiscard]] not_null<Main::Session*> session() const {
return _session;
}
// The shared lifecycle service, created on first use. Its blocking
// methods must be called through run() or runLocal(), never directly.
[[nodiscard]] auto lifecycle()
-> std::shared_ptr<wallet_engine::WalletLifecycle>;
// The client for one configured wallet. A live client must first be
// stopped through stopClient() and its callback awaited. Construction
// only validates the config, so it runs on the main thread. The
// config's toncenter_base_url must name the provider the MTProto
// proxy actually serves, in canonical form (lowercase host, no
// default port): the status-less transport reports no final URL, so
// this contract and the host's own origin check are the only origin
// guarantee the engine has.
void startClient(const wallet_engine::WalletClientConfig &config);
[[nodiscard]] auto client() const
-> std::shared_ptr<wallet_engine::WalletClient>;
// The domain's runtime, shared with every other signed-in account.
[[nodiscard]] VaultRuntime &vault() const;
// Runs the blocking client shutdown on the worker and reports on main.
// The client stays owned (and returned by client()) until `done` runs.
void stopClient(Fn<void()> done);
// Removes the protected-secret records a failed engine call wrote and
// forgets them. A call that succeeded never comes here: its handle is
// dropped instead.
void dropStoredSecrets(EngineSecretStores &stores);
// Runs a blocking engine call on the worker thread and delivers the
// result or the engine error on the main thread. Both callbacks are
// dropped when the Engine is destroyed before the job finishes. The
// queue does not coalesce: don't enqueue a repeatable operation, like
// a refresh, while the previous one is still outstanding. Waiters
// that only the client shutdown releases (wait_for_change) must not
// go through here: the serial worker could never advance them, and
// the destructor's queued shutdown would deadlock behind them.
template <typename Job, typename Done>
void run(Job job, Done done, Fn<void(EngineError)> fail) {
Enqueue(_worker, Package(
base::make_weak(this),
_vault,
_accountId,
privateAccess(),
std::move(job),
std::move(done),
std::move(fail)));
}
template <typename Job, typename Done>
void runLocal(Job job, Done done, Fn<void(EngineError)> fail) {
Enqueue(_localWorker, Package(
base::make_weak(this),
_vault,
_accountId,
privateAccess(),
std::move(job),
std::move(done),
std::move(fail)));
}
// Runs a short engine call (cancel_*) on the shared thread pool, so it
// can preempt a worker blocked inside a long call. Only for methods
// the engine contract documents as quick and non-blocking. The job
// must capture its client / lifecycle shared_ptr here, on the main
// thread, and must not touch storage, network or Engine members.
template <typename Job, typename Done>
void runQuick(Job job, Done done, Fn<void(EngineError)> fail) {
crl::async(Package(
base::make_weak(this),
_vault,
_accountId,
privateAccess(),
std::move(job),
std::move(done),
std::move(fail)));
}
private:
class StatuslessHost;
class PlatformHost;
struct Worker;
struct PrivateAccess {
quint32 epoch = 0;
bool used = false;
};
[[nodiscard]] std::shared_ptr<PrivateAccess> privateAccess() const;
[[nodiscard]] static bool PrivateResultCurrent(
const VaultRuntime &vault,
uint64 accountId,
const PrivateAccess &access);
[[nodiscard]] static EngineError PrivateAccessError();
static thread_local PrivateAccess *_privateAccess;
static void Execute(
base::weak_ptr<Engine> weak,
const std::shared_ptr<PrivateAccess> &access,
FnMut<void()> job,
Fn<void(EngineError)> fail);
// A job starts under one clear epoch even while it waits in the queue.
// Only a protected host call marks it private, so ordinary refreshes keep
// their normal completion behavior. A private result is checked again on
// main before delivery; plaintext already produced on the worker cannot
// escape through a delayed success callback after lock or rotation.
// SendResult reports the completed submission and its already journaled
// signed message, not fresh private access. Preserve that fact so a clear
// while awaiting the provider cannot turn an accepted send into failure.
// This exception never bypasses the protected host's own access checks or
// authorizes delivery of a phrase, decrypted comment or prepared signature.
template <typename Job, typename Done>
[[nodiscard]] static FnMut<void()> Package(
base::weak_ptr<Engine> weak,
std::shared_ptr<VaultRuntime> vault,
uint64 accountId,
std::shared_ptr<PrivateAccess> access,
Job job,
Done done,
Fn<void(EngineError)> fail) {
auto wrapped = [
weak,
vault,
accountId,
access,
fail,
job = std::move(job),
done = std::move(done)
]() mutable {
if constexpr (std::is_void_v<std::invoke_result_t<Job>>) {
job();
crl::on_main(weak, [
vault,
accountId,
access,
fail,
done = std::move(done)
] {
if (PrivateResultCurrent(*vault, accountId, *access)) {
done();
} else {
fail(PrivateAccessError());
}
});
} else {
auto result = job();
crl::on_main(weak, [
vault,
accountId,
access,
fail,
done = std::move(done),
result = std::move(result)
]() mutable {
if (std::is_same_v<decltype(result), wallet_engine::SendResult>
|| PrivateResultCurrent(*vault, accountId, *access)) {
done(std::move(result));
} else {
fail(PrivateAccessError());
}
});
}
};
return [
weak,
access,
wrapped = std::move(wrapped),
fail = std::move(fail)
]() mutable {
Execute(weak, access, std::move(wrapped), std::move(fail));
};
}
static void Enqueue(
std::unique_ptr<Worker> &worker,
FnMut<void()> task);
static void WorkerLoop(not_null<Worker*> worker);
const not_null<Main::Session*> _session;
const uint64 _accountId;
std::shared_ptr<StatuslessHost> _statuslessHost;
const std::shared_ptr<VaultRuntime> _vault;
std::shared_ptr<PlatformHost> _platformHost;
std::shared_ptr<wallet_engine::WalletLifecycle> _lifecycle;
std::shared_ptr<wallet_engine::WalletClient> _client;
std::unique_ptr<Worker> _worker;
std::unique_ptr<Worker> _localWorker;
rpl::lifetime _lifetime;
};
} // namespace Wallet