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:
2026-07-16 20:27:43 -05:00
parent c291e8a587
commit ef247c95ff
19 changed files with 133 additions and 22 deletions

View File

@@ -267,7 +267,10 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
out.body = parsed.value("b", std::string());
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent (0)
// A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it
// optimistically to Sent on load so it can't show a stuck spinner forever.
if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent;
return true;
} catch (const std::exception&) {
return false;

View File

@@ -10,9 +10,11 @@ namespace dragonx::chat {
enum class ChatDirection { Incoming, Outgoing };
enum class ChatMessageKind { Message, ContactRequest };
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no
// spendable address, a send already in progress). Always Sent for incoming.
enum class ChatDelivery { Sent, Failed };
// Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the
// daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected).
// Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST
// stay 0 and new states are APPENDED — never reordered.
enum class ChatDelivery { Sent, Failed, Sending };
struct ChatMessage {
ChatDirection direction = ChatDirection::Incoming;

View File

@@ -109,4 +109,15 @@ bool ChatService::recordOutgoing(const ChatMessage& message) {
return false;
}
bool ChatService::recordOutgoingPending(const ChatMessage& message) {
// In-memory only — deliberately NOT persisted yet, so "Sending" never survives to a restart; the
// row is written by resolveOutgoing() once the broadcast resolves to Sent/Failed.
return store_.append(message);
}
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
if (updated && db_) db_->append(*updated); // first persist, carrying the final status
}
} // namespace dragonx::chat

View File

@@ -76,6 +76,12 @@ public:
// only local record of what we sent.)
bool recordOutgoing(const ChatMessage& message);
// Two-phase echo for delivery tracking: record in-memory only (shows immediately as Sending),
// then resolveOutgoing() flips the status once the broadcast completes AND persists it with the
// final status — so a restart never shows a stuck "Sending".
bool recordOutgoingPending(const ChatMessage& message);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }

View File

@@ -34,6 +34,16 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
return out;
}
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
return &message;
}
}
return nullptr;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;

View File

@@ -21,6 +21,10 @@ public:
// Messages in a conversation, in insertion order.
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
// message (for the caller to persist), or nullptr if no message has that txid.
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
// Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const;