feat(chat): fee floor, real delivery status, pay-from-funded, funds pre-check
Chat sends move 0 value, so the network fee is structurally load-bearing (it's the only thing that forces a real shielded input; 0-value + 0-fee builds a degenerate, unrelayable tx). Three gaps addressed: 1. Fee floor — broadcastChatMemos now uses max(getDefaultFee(), kChatMinFeeDrgx), so a 0 / too-low global default-fee setting can't silently break chat. 2. Real delivery status — the echo was marked Sent on SUBMIT regardless of the on-chain outcome (the z_sendmany callback was empty), so failures were invisible and the Retry affordance never fired for async failures. Add a third ChatDelivery::Sending state (appended so persisted 0=Sent stays valid); record the echo in-memory as Sending, and resolve it to Sent/Failed from the z_sendmany completion callback — persisting only the final status (so a restart never shows a stuck spinner; a stray persisted Sending loads as Sent). A subtle "sending…" label shows while in flight. 3. Pay-from-funded + pre-check — z_sendmany spends from one z-address, and the identity reply address may be unfunded while funds sit elsewhere. chatPayFromZaddr picks a spendable z-address that can cover the fee (preferring the identity address); the memo still advertises the identity address as reply-to, so paying from a different note is transport-transparent. If nothing can cover the fee, a clear "need a small shielded balance" toast replaces the cryptic failure. Full node only for the callback path; lite resolves optimistically on queue. 8-language strings + CJK subset (+1 glyph 賄). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2885,6 +2885,27 @@ std::string App::chatReplyZaddr()
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string App::chatPayFromZaddr(double fee) const
|
||||
{
|
||||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
|
||||
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
|
||||
// spendable z-address that can cover the fee. The memo still advertises the identity address as
|
||||
// reply-to, so paying from a different note doesn't change who the peer replies to.
|
||||
std::string reply;
|
||||
if (settings_) reply = settings_->getChatReplyZaddr();
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
|
||||
|
||||
std::string best;
|
||||
double bestBal = -1.0;
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
|
||||
best = a.address;
|
||||
bestBal = a.balance;
|
||||
}
|
||||
return best; // empty → no z-address can cover the fee
|
||||
}
|
||||
|
||||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||||
// new conversation ids. Random — collisions are astronomically unlikely.
|
||||
std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||||
@@ -2904,21 +2925,35 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||||
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
|
||||
// on receive, that recipient-array order maps to note position (the receive pairing needs the header
|
||||
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
|
||||
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
|
||||
// A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a
|
||||
// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the
|
||||
// global default-fee setting drop chat below a working minimum (well above the node's min relay fee).
|
||||
static constexpr double kChatMinFeeDrgx = 0.0001;
|
||||
|
||||
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId)
|
||||
{
|
||||
if (memos.recipientZaddr.empty()) return false;
|
||||
|
||||
if (lite_wallet_) {
|
||||
return broadcastChatMemosLite(memos);
|
||||
const bool ok = broadcastChatMemosLite(memos);
|
||||
// The lite backend has no per-send completion callback here; resolve optimistically on a
|
||||
// successful queue (its own broadcast log surfaces a later failure).
|
||||
if (ok) chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
|
||||
return ok;
|
||||
}
|
||||
|
||||
if (!state_.connected || !rpc_ || !worker_) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
|
||||
return false;
|
||||
}
|
||||
const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address
|
||||
|
||||
const double fee = std::max(settings_ ? settings_->getDefaultFee() : kChatMinFeeDrgx, kChatMinFeeDrgx);
|
||||
|
||||
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
|
||||
// reply address, so the peer replies to the right place regardless of which note paid.
|
||||
const std::string from = chatPayFromZaddr(fee);
|
||||
if (from.empty()) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2933,12 +2968,16 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
|
||||
recipients.push_back(std::move(recipient));
|
||||
}
|
||||
|
||||
const double fee = settings_ ? settings_->getDefaultFee() : 0.0001;
|
||||
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
|
||||
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
|
||||
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
|
||||
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{});
|
||||
return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller)
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
||||
[this, echoLocalId](bool ok, const std::string& /*result*/) {
|
||||
chat_service_.resolveOutgoing(echoLocalId,
|
||||
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
|
||||
});
|
||||
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
||||
}
|
||||
|
||||
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
|
||||
@@ -2997,8 +3036,6 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
||||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
||||
return;
|
||||
}
|
||||
const bool submitted = broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::Message;
|
||||
@@ -3009,8 +3046,12 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
||||
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
||||
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
|
||||
// affordance appears; otherwise the async callback flips it to Sent/Failed.
|
||||
if (!broadcastChatMemos(memos, echo.txid))
|
||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||||
}
|
||||
|
||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||
@@ -3030,8 +3071,6 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
|
||||
ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
|
||||
return;
|
||||
}
|
||||
const bool submitted = broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::ContactRequest;
|
||||
@@ -3041,9 +3080,12 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||
echo.delivery = chat::ChatDelivery::Sending;
|
||||
chat_service_.recordOutgoingPending(echo);
|
||||
if (broadcastChatMemos(memos, echo.txid))
|
||||
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||
else
|
||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||||
}
|
||||
|
||||
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
|
||||
|
||||
Reference in New Issue
Block a user