From eb9aa293eeec87dd1fb0bd8d5ff268578efb3eb5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 06:05:46 -0700 Subject: [PATCH 1/7] Specify Like / Tip a Memo By specifier. --- dev-docs/psf-memo-db-changes.md | 63 ++++++++++++++++++ specs/like-tip-memo.feature | 112 ++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 dev-docs/psf-memo-db-changes.md create mode 100644 specs/like-tip-memo.feature diff --git a/dev-docs/psf-memo-db-changes.md b/dev-docs/psf-memo-db-changes.md new file mode 100644 index 0000000..136525c --- /dev/null +++ b/dev-docs/psf-memo-db-changes.md @@ -0,0 +1,63 @@ +# psf-memo-db changes to support Like counts + +**Status**: DRAFT — notes for a future session. The current Like/Tip feature +(`0x6d04`) focuses on the **UI to broadcast a like** (and an optional tip). The +read-side changes below are **not** implemented now; they are recorded here so the +like-count read path can be developed later. + +Owner: specifier. +Last updated: 2026-08-26. + +--- + +## Goal + +Expose like counts (and, later, liked-state and a likers list) so the psf-memo-client +UI can show a real like count on each post and whether the viewing user already liked +it. Today `/posts/*` responses omit likes entirely. + +## What the indexer already provides + +The Memo **indexer** (`psf-memo-indexer`) already parses `0x6d04` like/tip actions into +the DB as social references: a **liker address** → a **liked post txid** (with an +optional tip value). This feature does not require indexer changes to record likes; it +requires the **DB query/API** layer to aggregate and expose them. + +## Required psf-memo-db changes + +1. **`likeCount` on post responses.** + Add a `likeCount` field (number of distinct `0x6d04` references whose liked txid + equals the post txid) to the objects returned by: + - `/posts/recent` (feed items) + - `/post/:txid` (thread root) + - thread reply nodes (when replies are also likeable / shown with counts) + Aggregate the count in the query rather than an N+1 per-post lookup. + +2. **Liked-state for the viewing user (optional, later).** + To render a filled heart when the current wallet has already liked a post, the + read endpoints need to know the viewer. Add an optional `viewer=
` query + param (or equivalent) to the relevant post endpoints and return + `liked: true|false` per post based on whether `viewer` has a `0x6d04` reference to + that txid. Until this exists, the client can track "liked" locally/optimistically + for the current session only. + +3. **Likers list endpoint (later).** + memo.cash shows a modal listing who liked a post (its `post/likes`). Add an endpoint + e.g. `GET /post/:txid/likes` returning `[{ address, name, profilePicUrl, tip }...]` + for the addresses that liked the post, ordered by time/tip, joined with the profile + store to avoid N+1 lookups. Used by a future "likes" modal. + +4. **Join efficiency.** + Like counts must be aggregated server-side (e.g. a counter derived from the + reference index or a materialized count) and included in the same response as the + post text, author, name, and avatar — avoid N+1 per-item like lookups in feed and + thread responses. + +## Out of scope (this session) + +- The like-count **read surface** (count badge, liked-state, likers modal). +- `viewer` liked-state param. +- Likers list endpoint. + +All of the above are future work; the UI spec for this session only broadcasts the +like/tip and increments a count **optimistically**. diff --git a/specs/like-tip-memo.feature b/specs/like-tip-memo.feature new file mode 100644 index 0000000..14d0065 --- /dev/null +++ b/specs/like-tip-memo.feature @@ -0,0 +1,112 @@ +# Scenarios: Like / Tip a Memo - 1, Like / Tip a Memo - 2, Like / Tip a Memo - 3, Like / Tip a Memo - 4, Like / Tip a Memo - 5, Like / Tip a Memo - 6, Like / Tip a Memo - 7, Like / Tip a Memo - 8, Like / Tip a Memo - 9, Like / Tip a Memo - 10 +Feature: Like / Tip a Memo + + Background: + Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d + Given the wallet has a spendable balance of 100000 sats + Given a post with the txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa authored by the author address + + Scenario: Like / Tip a Memo - 1 the heart icon opens the like/tip modal + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then a like/tip modal opens for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + Scenario Outline: Like / Tip a Memo - 2 a pure like broadcasts the Memo like action + Given a post with the txid authored by the author address + When I click the heart icon on the post with txid + When I submit the like without a tip + Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid + Then the wallet sends no tip + Then the like count on the post increases by one + Then the heart icon on the post shows as filled + + Examples: + | txid | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | + | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | + + Scenario Outline: Like / Tip a Memo - 3 a like with a tip broadcasts and pays the author + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then the wallet sends a tip of to the author address + Then the like count on the post increases by one + Then the heart icon on the post shows as filled + + Examples: + | tip | + | 3000 | + | 25000 | + + Scenario Outline: Like / Tip a Memo - 4 an invalid tip is rejected + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the like/tip modal shows an error containing "valid number" + Then the wallet does not broadcast any transaction + + Examples: + | tip | + | 1.5 | + | abc | + + Scenario Outline: Like / Tip a Memo - 5 a tip below the dust limit is rejected + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the like/tip modal shows an error containing "dust limit" + Then the wallet does not broadcast any transaction + + Examples: + | tip | + | 1 | + | 2999 | + + Scenario: Like / Tip a Memo - 6 a tip above the maximum is rejected + Given the wallet has a spendable balance of 150000000 sats + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of 100000001 + When I submit the like + Then the like/tip modal shows an error containing "maximum" + Then the wallet does not broadcast any transaction + + Scenario Outline: Like / Tip a Memo - 7 a tip above the spendable balance is rejected + Given the wallet has a spendable balance of sats + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the like/tip modal shows an error containing "spendable" + Then the wallet does not broadcast any transaction + + Examples: + | balance | tip | + | 30000 | 35000 | + | 500000 | 550000 | + + Scenario Outline: Like / Tip a Memo - 8 a user without spendable balance cannot like + Given the wallet has a spendable balance of sats + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then the like/tip modal shows an error containing "add BCH" + Then the wallet does not broadcast any transaction + + Examples: + | balance | + | 0 | + | 2999 | + + Scenario Outline: Like / Tip a Memo - 9 a user can like their own post + Given a post with the txid authored by my address + When I click the heart icon on the post with txid + When I submit the like without a tip + Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid + Then the like count on the post increases by one + + Examples: + | txid | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | + | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | + + Scenario: Like / Tip a Memo - 10 the cancel button closes the like/tip modal + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I click the cancel button + Then the like/tip modal closes From dd0535d13340721c22a254de598e0c468e1a6ff5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 06:14:01 -0700 Subject: [PATCH 2/7] Implement Like / Tip a Memo Adds the Memo like action (0x6d04) with optional author tip, a like/tip page controller, heart icon + modal UI, and acceptance handlers for specs/like-tip-memo.feature. By coder. --- acceptance/lib/handlers.js | 240 +++++++++++++++- src/components/app-body/posts/index.js | 1 + src/components/post-feed/like-button.js | 37 +++ src/components/post-feed/like-tip-modal.js | 133 +++++++++ src/components/post-feed/post-feed-item.js | 52 +++- src/components/post-feed/post-feed.css | 59 ++++ src/components/post-thread-modal/index.js | 3 +- .../post-thread-modal/post-thread-node.js | 4 +- src/services/hex.js | 28 ++ src/services/like-tip-page.js | 106 +++++++ src/services/memo-like.js | 167 +++++++++++ src/services/memo-reply.js | 19 +- src/services/optimistic-reply.js | 1 - test/helpers/fake-wallet.js | 14 +- test/unit/like-tip-page.test.js | 230 +++++++++++++++ test/unit/memo-like.test.js | 261 ++++++++++++++++++ 16 files changed, 1323 insertions(+), 32 deletions(-) create mode 100644 src/components/post-feed/like-button.js create mode 100644 src/components/post-feed/like-tip-modal.js create mode 100644 src/services/hex.js create mode 100644 src/services/like-tip-page.js create mode 100644 src/services/memo-like.js create mode 100644 test/unit/like-tip-page.test.js create mode 100644 test/unit/memo-like.test.js diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index d2b7ef0..e2eaba8 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -26,10 +26,16 @@ const ReplyThreadPage = require('../../src/services/reply-thread-page') const MemoSetName = require('../../src/services/memo-set-name') const SetNamePage = require('../../src/services/set-name-page') const AccountPage = require('../../src/services/account-page') +const MemoLike = require('../../src/services/memo-like') +const LikeTipPage = require('../../src/services/like-tip-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX +const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX + +// Default author address used by Gherkin steps that refer to "the author address". +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' // A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. function makeWallet (address) { @@ -40,9 +46,9 @@ function makeWallet (address) { getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (msg, prefix) { + sendOpReturn: async function (msg, prefix, bchOutput = []) { // Record the broadcast attempt, then fail if configured to do so. - this.broadcasts.push({ msg, prefix }) + this.broadcasts.push({ msg, prefix, bchOutput }) if (this.failWith) throw new Error(this.failWith) return 'aa'.repeat(32) } @@ -86,14 +92,18 @@ function createWorld () { const memoPost = new MemoPost({ wallet, feed }) const thread = makeThread() const memoReply = new MemoReply({ wallet, thread }) + const memoLike = new MemoLike({ wallet, feed }) + const world = { wallet, feed, thread, memoPost, memoReply, + memoLike, currentPath: null, - menuOpen: false + menuOpen: false, + likedTxids: new Set() } // The New Post Page controller wraps the memo post behavior. Its navigate @@ -111,6 +121,9 @@ function createWorld () { navigate: () => {} }) + // The Like / Tip Page controller wraps the memo like behavior. + world.likeTipPage = new LikeTipPage({ memoLike }) + // The Set Name Page and Account Page controllers share a profile store so // a name set on one page is visible on the other. const profiles = makeProfiles() @@ -136,6 +149,24 @@ function decodeReplyPayload (raw) { return { parentTxid, text } } +// Decode a raw like payload back into the liked post txid (hex). +function decodeLikeTxid (raw) { + return Buffer.from(raw).toString('hex') +} + +// Resolve a literal value or a placeholder from the example store. +function resolveParam (value, example) { + const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim()) + if (match) { + const param = match[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + return example[param] + } + return String(value).trim() +} + // Handler registry. Each entry: { pattern, run }. // run receives (match, exampleStore, world, step). const handlers = [ @@ -562,6 +593,209 @@ const handlers = [ throw new Error('Account page does not show a Set Name button.') } } + }, + { + name: 'wallet has spendable balance', + pattern: /^the wallet has a spendable balance of (.+) sats$/, + run (m, example, world) { + const balance = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(balance)) { + throw new Error(`Invalid balance value "${m[1]}"`) + } + world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }] + } + }, + { + name: 'post with txid authored by author address', + pattern: /^a post with the txid (.+) authored by the author address$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = { + txid, + addr: AUTHOR_ADDRESS, + address: AUTHOR_ADDRESS, + text: 'A sample post', + likeCount: 0 + } + world.feed.addPost(post) + } + }, + { + name: 'post with txid authored by my address', + pattern: /^a post with the txid (.+) authored by my address$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const myAddress = world.wallet.walletInfo.cashAddress + const post = { + txid, + addr: myAddress, + address: myAddress, + text: 'My own post', + likeCount: 0 + } + world.feed.addPost(post) + } + }, + { + name: 'click heart icon on post', + pattern: /^I click the heart icon on the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = world.feed.posts.find((p) => p.txid === txid) + const authorAddress = post ? post.addr : AUTHOR_ADDRESS + world.likeTipPage.open(txid, authorAddress) + } + }, + { + name: 'like/tip modal opens for post', + pattern: /^a like\/tip modal opens for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + if (!world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to be open.') + } + if (world.likeTipPage.postTxid !== txid) { + throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`) + } + } + }, + { + name: 'submit like without tip', + pattern: /^I submit the like without a tip$/, + async run (m, example, world) { + world.likeTipPage.setTip('') + const result = await world.likeTipPage.submit() + if (result.ok) { + world.likedTxids.add(world.likeTipPage.postTxid) + } + } + }, + { + name: 'enter tip', + pattern: /^I enter a tip of (.+)$/, + run (m, example, world) { + world.likeTipPage.setTip(resolveParam(m[1], example)) + } + }, + { + name: 'submit like', + pattern: /^I submit the like$/, + async run (m, example, world) { + const result = await world.likeTipPage.submit() + if (result.ok) { + world.likedTxids.add(world.likeTipPage.postTxid) + } + } + }, + { + name: 'broadcasts OP_RETURN with Memo like prefix', + pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_LIKE_PREFIX) { + throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`) + } + if (decodeLikeTxid(last.msg) !== txid) { + throw new Error(`Broadcast liked txid did not match ${txid}.`) + } + } + }, + { + name: 'wallet sends no tip', + pattern: /^the wallet sends no tip$/, + run (m, example, world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) { + throw new Error('Expected no tip output, but one was present.') + } + } + }, + { + name: 'wallet sends tip to author', + pattern: /^the wallet sends a tip of (.+) to the author address$/, + run (m, example, world) { + const expectedTip = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(expectedTip)) { + throw new Error(`Invalid tip value "${m[1]}"`) + } + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) { + throw new Error('Expected a tip output, but none was present.') + } + const tipOutput = last.bchOutput[0] + if (tipOutput.amountSat !== expectedTip) { + throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`) + } + const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid) + const expectedAddress = post ? post.addr : AUTHOR_ADDRESS + if (tipOutput.address !== expectedAddress) { + throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`) + } + } + }, + { + name: 'like count increases by one', + pattern: /^the like count on the post increases by one$/, + run (m, example, world) { + const postTxid = world.likeTipPage.postTxid + const post = world.feed.posts.find((p) => p.txid === postTxid) + if (!post) { + throw new Error(`Post ${postTxid} not found in feed.`) + } + if (post.likeCount !== 1) { + throw new Error(`Expected like count to be 1, got ${post.likeCount}.`) + } + } + }, + { + name: 'heart icon shows as filled', + pattern: /^the heart icon on the post shows as filled$/, + run (m, example, world) { + const postTxid = world.likeTipPage.postTxid + if (!world.likedTxids.has(postTxid)) { + throw new Error(`Expected heart icon to be filled for ${postTxid}.`) + } + } + }, + { + name: 'like/tip modal shows error containing text', + pattern: /^the like\/tip modal shows an error containing "(.+)"$/, + run (m, example, world) { + const expected = m[1] + const actual = world.likeTipPage.broadcastError || '' + if (!actual.includes(expected)) { + throw new Error(`Expected an error containing "${expected}", got "${actual}".`) + } + } + }, + { + name: 'click cancel button', + pattern: /^I click the cancel button$/, + run (m, example, world) { + world.likeTipPage.close() + } + }, + { + name: 'like/tip modal closes', + pattern: /^the like\/tip modal closes$/, + run (m, example, world) { + if (world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to be closed.') + } + } } ] diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index 3172b0a..eee1fe8 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -131,6 +131,7 @@ function RecentPosts (props) { key={post.txid} post={post} profiles={profiles} + wallet={appData?.wallet} onReplyClick={() => openThread(post.txid)} showFooterMeta /> diff --git a/src/components/post-feed/like-button.js b/src/components/post-feed/like-button.js new file mode 100644 index 0000000..ff28eb7 --- /dev/null +++ b/src/components/post-feed/like-button.js @@ -0,0 +1,37 @@ +/* + Like button for a post (heart icon + count). + + The icon is filled when the post has been liked in the current session and + outlined otherwise. The count is displayed next to the icon. +*/ + +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons' +import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons' + +import './post-feed.css' + +function LikeButton ({ count = 0, liked = false, onClick }) { + const label = count === 1 ? '1 like' : `${count} likes` + const icon = liked ? faHeartSolid : faHeartRegular + const className = [ + 'post-like-button', + liked ? 'post-like-button-liked' : '' + ].filter(Boolean).join(' ') + + return ( + + ) +} + +export default LikeButton diff --git a/src/components/post-feed/like-tip-modal.js b/src/components/post-feed/like-tip-modal.js new file mode 100644 index 0000000..4d0af8b --- /dev/null +++ b/src/components/post-feed/like-tip-modal.js @@ -0,0 +1,133 @@ +/* + Like / Tip modal for a post. + + Lets the user submit a Memo like (0x6d04) with an optional satoshi tip to the + post author. The wallet and target post are injected through props. Errors + from validation, dust/maximum/balance checks, and broadcast failures are + surfaced in the modal body. +*/ + +import React, { useState, useEffect } from 'react' +import { Modal, Form, Button } from 'react-bootstrap' + +import MemoLike from '../../services/memo-like' +import LikeTipPage from '../../services/like-tip-page' +import { getDisplayName } from './post-display' +import './post-feed.css' + +function formatError (result) { + if (!result || result.ok) return '' + return result.message || '' +} + +function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) { + const [tip, setTip] = useState('') + const [error, setError] = useState('') + const [submitting, setSubmitting] = useState(false) + + const displayName = post ? getDisplayName(post.addr, profiles) : '' + + // Reset state whenever the modal is shown and check the wallet balance. + useEffect(() => { + if (!show || !post || !wallet) { + setTip('') + setError('') + setSubmitting(false) + return + } + + setTip('') + setError('') + setSubmitting(false) + + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + const result = page.open(post.txid, post.addr) + if (!result.ok) { + setError(formatError(result)) + } + }, [show, post, wallet]) + + async function handleSubmit () { + if (!post || !wallet) return + setError('') + setSubmitting(true) + + try { + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + page.open(post.txid, post.addr) + page.setTip(tip) + + const result = await page.submit() + if (result.ok) { + setTip('') + if (typeof onSuccess === 'function') { + onSuccess() + } + } else { + setError(formatError(result)) + } + } catch (submitErr) { + setError(submitErr.message) + } finally { + setSubmitting(false) + } + } + + function handleCancel () { + setTip('') + setError('') + onHide() + } + + return ( + + + Like / Tip + + + + {post && ( +

+ Like the post by {displayName} +

+ )} + +
{ e.preventDefault(); handleSubmit() }}> + + Tip (satoshis, optional) + setTip(e.target.value)} + disabled={submitting || !!error} + /> + +
+ + {error && ( +

{error}

+ )} +
+ + + + + +
+ ) +} + +export default LikeTipModal diff --git a/src/components/post-feed/post-feed-item.js b/src/components/post-feed/post-feed-item.js index 2f1aa3b..16357d0 100644 --- a/src/components/post-feed/post-feed-item.js +++ b/src/components/post-feed/post-feed-item.js @@ -2,12 +2,14 @@ Instagram-style post card for feed and thread views. */ -import React from 'react' +import React, { useState } from 'react' import { Link } from 'react-router-dom' import AppUtil from '../../util' import PostReplyCount from '../post-reply-count' import PostThreadAvatar from '../post-thread-modal/post-thread-avatar' +import LikeButton from './like-button' +import LikeTipModal from './like-tip-modal' import { formatRelativeSeen, getDisplayName, @@ -21,14 +23,20 @@ function PostFeedItem ({ post, profile = {}, profiles = {}, + wallet, onReplyClick, showRepliedLabel = false, showFooterMeta = false, showReplyCount = true, + showLikeButton = true, embedded = false }) { if (!post) return null + const [liked, setLiked] = useState(false) + const [likeCount, setLikeCount] = useState(post.likeCount || 0) + const [showLikeModal, setShowLikeModal] = useState(false) + const displayName = getDisplayName(post.addr, profiles) const hasCustomName = Boolean( @@ -46,6 +54,20 @@ function PostFeedItem ({ appUtil.copyToClipboard(post.txid) } + const handleLikeClick = () => { + setShowLikeModal(true) + } + + const handleLikeSuccess = () => { + setLiked(true) + setLikeCount((count) => count + 1) + setShowLikeModal(false) + } + + const handleLikeModalHide = () => { + setShowLikeModal(false) + } + return ( - {showReplyCount && ( + {(showReplyCount || showLikeButton) && (
- + {showLikeButton && ( + + )} + {showReplyCount && ( + + )}
)} @@ -147,6 +178,15 @@ function PostFeedItem ({ )} + +
) } diff --git a/src/components/post-feed/post-feed.css b/src/components/post-feed/post-feed.css index e31cc51..96009ea 100644 --- a/src/components/post-feed/post-feed.css +++ b/src/components/post-feed/post-feed.css @@ -602,4 +602,63 @@ .recent-posts-pagination { padding: 0 0.75rem; } +} + +.post-like-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + + min-height: 32px; + padding: 4px 0; + + color: var(--ig-text); + background: transparent; + + border: 0; + border-radius: 0; + + font-size: 13px; + font-weight: 600; + line-height: 18px; + text-decoration: none; + + cursor: pointer; + + transition: + opacity 0.15s ease, + color 0.15s ease; +} + +.post-like-button:hover { + color: var(--ig-danger); + background: transparent; + border: 0; + box-shadow: none; + transform: none; + opacity: 0.65; +} + +.post-like-button-liked, +.post-like-button-liked:hover { + color: var(--ig-danger); + opacity: 1; +} + +.post-like-button-icon { + font-size: 18px; +} + +.post-like-button-count { + min-width: 1.2em; +} + +.like-tip-modal-target { + margin-bottom: 1rem; +} + +.like-tip-modal-error { + margin: 0; + font-size: 0.95rem; } \ No newline at end of file diff --git a/src/components/post-thread-modal/index.js b/src/components/post-thread-modal/index.js index f359540..6305037 100644 --- a/src/components/post-thread-modal/index.js +++ b/src/components/post-thread-modal/index.js @@ -107,7 +107,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil {!loading && !error && thread && ( <> - + ))} diff --git a/src/components/post-thread-modal/post-thread-node.js b/src/components/post-thread-modal/post-thread-node.js index b4a7aa4..82341a2 100644 --- a/src/components/post-thread-modal/post-thread-node.js +++ b/src/components/post-thread-modal/post-thread-node.js @@ -6,7 +6,7 @@ import React from 'react' import PostFeedItem from '../post-feed/post-feed-item' -function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { +function PostThreadNode ({ post, profiles = {}, wallet, depth = 0, isRoot = false }) { if (!post) return null const profile = profiles[post.addr] || {} @@ -21,6 +21,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { post={post} profile={profile} profiles={profiles} + wallet={wallet} showRepliedLabel={!isRoot} showReplyCount={false} embedded @@ -30,6 +31,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { key={reply.txid} post={reply} profiles={profiles} + wallet={wallet} depth={depth + 1} /> ))} diff --git a/src/services/hex.js b/src/services/hex.js new file mode 100644 index 0000000..5ef989a --- /dev/null +++ b/src/services/hex.js @@ -0,0 +1,28 @@ +/* + Hex encoding helpers used by Memo protocol actions. + + A Bitcoin Cash transaction id is a 32-byte value encoded as a 64-character + hex string. Memo actions like replies and likes need to embed that txid in + the OP_RETURN payload as raw bytes, so this module provides a small, + testable conversion helper. +*/ + +// Decode a hex string into a Uint8Array of the requested byte length. +// The label parameter customizes error messages for the caller's context. +function hexToBytes (hex, byteLength = 32, label = 'Value') { + if (typeof hex !== 'string' || hex.length !== byteLength * 2) { + throw new Error(`${label} must be a ${byteLength * 2}-character hex string.`) + } + + const bytes = new Uint8Array(byteLength) + for (let i = 0; i < hex.length; i += 2) { + const byte = parseInt(hex.substr(i, 2), 16) + if (Number.isNaN(byte)) { + throw new Error(`${label} must be a valid hex string.`) + } + bytes[i / 2] = byte + } + return bytes +} + +module.exports = { hexToBytes } diff --git a/src/services/like-tip-page.js b/src/services/like-tip-page.js new file mode 100644 index 0000000..7a0c48c --- /dev/null +++ b/src/services/like-tip-page.js @@ -0,0 +1,106 @@ +/* + Like / Tip page behavior: open a modal for a post, validate an optional tip, + and submit a Memo like (0x6d04) with or without a tip. + + This is the testable controller behind the React like/tip modal. It wraps + the Memo like behavior (src/services/memo-like.js) and adds page-level + concerns: holding the target post txid, holding the tip input as a string, + surfacing validation/dust/maximum/balance errors, and closing the modal on + success or cancellation. + + The memoLike and modal state concerns are injected so this module stays free + of UI/network concerns; environmentally unsuitable I/O lives behind those + small adapter boundaries. +*/ + +const PageController = require('./page-controller') + +class LikeTipPage extends PageController { + constructor (deps = {}) { + super(deps) + this.memoLike = deps.memoLike || null + this.tipping = false + this.modalOpen = false + this.postTxid = deps.postTxid || null + this.authorAddress = deps.authorAddress || '' + this.successPath = null + this.validationCodes = ['like_validation', 'like_dust', 'like_maximum', 'like_balance', 'like_empty_balance'] + } + + // Open the like/tip modal for a post and check that the wallet has enough + // spendable balance to cover fees. Returns a result object. + open (postTxid, authorAddress) { + this.postTxid = postTxid + this.authorAddress = authorAddress + this.modalOpen = true + this.submitError = null + this.broadcastError = null + + if (!this.memoLike) { + this.submitError = 'like_validation' + this.broadcastError = 'Like requires a memo like handler.' + return { ok: false, error: this.submitError, message: this.broadcastError } + } + + try { + const spendable = this.memoLike.getSpendableSats() + if (spendable < this.memoLike.dustLimit) { + const err = new Error('add BCH to your wallet before liking a post.') + err.code = 'like_empty_balance' + throw err + } + } catch (err) { + return this._handleSubmitFailure(err) + } + + return { ok: true } + } + + // Close the modal and reset input/errors. + close () { + this.modalOpen = false + this.submitError = null + this.broadcastError = null + this.input = '' + } + + // Set the tip amount as a raw string. The string form is validated on submit + // so non-numeric or decimal input can be rejected. + setTip (tipStr) { + this.setInput(tipStr) + } + + // Set the in-flight tipping flag. + _setBusy (value) { + this.tipping = value + } + + // Parse a non-empty tip string into an integer number of satoshis. + _parseTip (input) { + if (input === '' || input === null || input === undefined) return 0 + if (!/^\d+$/.test(String(input))) { + const err = new Error('Tip must be a valid number of satoshis.') + err.code = 'like_validation' + throw err + } + return parseInt(input, 10) + } + + // Run the memo like action for the current post and tip. + async _perform (input) { + if (!this.memoLike) { + throw new Error('Like requires a memo like handler.') + } + const tipSats = this._parseTip(input) + return this.memoLike.like(this.postTxid, tipSats, this.authorAddress) + } + + // Surface the real error message for every failure, including local + // validation failures, so the like/tip modal can display it. + _handleSubmitFailure (err) { + this.broadcastError = err.message || String(err) + return super._handleSubmitFailure(err) + } +} + +module.exports = LikeTipPage diff --git a/src/services/memo-like.js b/src/services/memo-like.js new file mode 100644 index 0000000..eb8ff60 --- /dev/null +++ b/src/services/memo-like.js @@ -0,0 +1,167 @@ +/* + Memo like/tip behavior: compose, validate, and broadcast a Memo "like" + action with an optional tip. + + A Memo like is an OP_RETURN Bitcoin Cash transaction carrying the Memo like + protocol prefix (0x6d04) followed by the liked post transaction hash (32 + bytes). When the user adds a tip, an additional P2PKH output sending the tip + amount to the post author's address is included in the same transaction. + + Broadcasting is done through a wallet that exposes the minimal-slp-wallet + adapter surface (walletInfo, getUtxos(), sendOpReturn(msg, prefix, bchOutput)). + + The wallet and feed are injected so this module stays testable and free of + network/UI concerns; environmentally unsuitable I/O lives behind those small + adapter boundaries. + + Constants + MEMO_LIKE_PREFIX : hex prefix for the Memo "like" action (0x6d04) + DUST_LIMIT_SATS : smallest non-dust BCH output (3000 sats) + MAX_TIP_SATS : sanity maximum for a single tip (100000000 sats = 1 BCH) + PARENT_TXID_BYTES : liked post txid size in bytes (32) +*/ + +const MemoAction = require('./memo-action') +const { hexToBytes } = require('./hex') + +const MEMO_LIKE_PREFIX = '6d04' +const DUST_LIMIT_SATS = 3000 +const MAX_TIP_SATS = 100000000 +const PARENT_TXID_BYTES = 32 + +class MemoLike extends MemoAction { + static config = { + prefix: MEMO_LIKE_PREFIX, + walletRequiredMsg: 'Memo like requires a wallet.', + lengthMessage: 'Post txid must be a 64-character hex string.', + emptyMessage: 'Post txid must be a 64-character hex string.', + lengthCode: 'like_validation', + validationCode: 'like_validation' + } + + constructor (deps = {}) { + super(deps) + this.feed = deps.feed + this.dustLimit = deps.dustLimit || DUST_LIMIT_SATS + this.maxTip = deps.maxTip || MAX_TIP_SATS + } + + // Validate a candidate post txid. + // Returns { ok: true } or throws a typed validation error. + validate (postTxid) { + try { + hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid') + return { ok: true } + } catch (err) { + const validationErr = new Error(err.message) + validationErr.code = this.validationCode + throw validationErr + } + } + + // Validate an optional tip amount against the dust limit, hard maximum, and + // the wallet's spendable balance. + validateTip (tipSats, spendableSats) { + if (!Number.isInteger(tipSats) || tipSats < 0) { + const err = new Error('Tip must be a valid number of satoshis.') + err.code = 'like_validation' + throw err + } + + if (tipSats > 0 && tipSats < this.dustLimit) { + const err = new Error(`Tip is below the dust limit of ${this.dustLimit} sats.`) + err.code = 'like_dust' + throw err + } + + if (tipSats > this.maxTip) { + const err = new Error(`Tip exceeds the maximum of ${this.maxTip} sats.`) + err.code = 'like_maximum' + throw err + } + + if (tipSats > spendableSats) { + const err = new Error('Tip exceeds the spendable balance.') + err.code = 'like_balance' + throw err + } + + return { ok: true } + } + + // Sum the wallet's spendable UTXOs. Tolerates the common value field names + // used by different wallet adapters. + getSpendableSats () { + if (!this.wallet) return 0 + const utxos = this.wallet.utxos || [] + return utxos.reduce((sum, u) => { + const value = u.value ?? u.satoshis ?? u.amount ?? 0 + return sum + value + }, 0) + } + + // Compose and broadcast a Memo like for the given post txid. + // tipSats is optional (defaults to 0). authorAddress is required when + // tipSats is greater than 0. + // Resolves with the transaction id, or rejects with a typed error. + async like (postTxid, tipSats = 0, authorAddress = '') { + this.validate(postTxid) + + if (!this.wallet) { + throw new Error(this.walletRequiredMsg) + } + + await this.wallet.getUtxos() + + const spendable = this.getSpendableSats() + if (spendable < this.dustLimit) { + const err = new Error('add BCH to your wallet before liking a post.') + err.code = 'like_empty_balance' + throw err + } + + this.validateTip(tipSats, spendable) + + if (tipSats > 0 && (!authorAddress || typeof authorAddress !== 'string')) { + const err = new Error('Tip requires an author address.') + err.code = 'like_validation' + throw err + } + + const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid') + const bchOutput = tipSats > 0 + ? [{ address: authorAddress, amountSat: tipSats }] + : [] + + const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput) + + this.reflect(txid, postTxid, tipSats) + + return txid + } + + // Record the new like on the injected feed when one is present. + reflect (txid, postTxid, tipSats) { + if (this.feed && typeof this.feed.addLike === 'function') { + this.feed.addLike({ + txid, + postTxid, + address: this.wallet.walletInfo.cashAddress, + tipSats + }) + } + + if (this.feed && Array.isArray(this.feed.posts)) { + const post = this.feed.posts.find((p) => p.txid === postTxid) + if (post) { + post.likeCount = (post.likeCount || 0) + 1 + } + } + } +} + +MemoLike.MEMO_LIKE_PREFIX = MEMO_LIKE_PREFIX +MemoLike.DUST_LIMIT_SATS = DUST_LIMIT_SATS +MemoLike.MAX_TIP_SATS = MAX_TIP_SATS + +module.exports = MemoLike diff --git a/src/services/memo-reply.js b/src/services/memo-reply.js index 880e72f..ec9f0ce 100644 --- a/src/services/memo-reply.js +++ b/src/services/memo-reply.js @@ -18,6 +18,7 @@ const MemoAction = require('./memo-action') const { byteLength } = require('./utf8') +const { hexToBytes } = require('./hex') const MEMO_REPLY_PREFIX = '6d03' const MAX_REPLY_BYTES = 184 @@ -82,7 +83,7 @@ class MemoReply extends MemoAction { // Build the raw OP_RETURN message payload for a reply. // The protocol wire format is: . function buildReplyPayload (parentTxid, message) { - const parentBytes = hexToBytes(parentTxid) + const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, 'Parent txid') const textBytes = new TextEncoder().encode(message) const raw = new Uint8Array(parentBytes.length + textBytes.length) raw.set(parentBytes, 0) @@ -90,22 +91,6 @@ function buildReplyPayload (parentTxid, message) { return raw } -// Decode a 64-character hex transaction id into 32 raw bytes. -function hexToBytes (hex) { - if (typeof hex !== 'string' || hex.length !== PARENT_TXID_BYTES * 2) { - throw new Error('Parent txid must be a 64-character hex string.') - } - const bytes = new Uint8Array(PARENT_TXID_BYTES) - for (let i = 0; i < hex.length; i += 2) { - const byte = parseInt(hex.substr(i, 2), 16) - if (Number.isNaN(byte)) { - throw new Error('Parent txid must be a valid hex string.') - } - bytes[i / 2] = byte - } - return bytes -} - MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES diff --git a/src/services/optimistic-reply.js b/src/services/optimistic-reply.js index 3f52a2b..2e96f45 100644 --- a/src/services/optimistic-reply.js +++ b/src/services/optimistic-reply.js @@ -1,5 +1,4 @@ - // Build the optimistic reply object used by the reply form for immediate // thread rendering before the thread is refreshed from the network. This is a // pure data-shaping function so the reply object shape is unit-testable and diff --git a/test/helpers/fake-wallet.js b/test/helpers/fake-wallet.js index 2d2feb0..d5ee57c 100644 --- a/test/helpers/fake-wallet.js +++ b/test/helpers/fake-wallet.js @@ -6,20 +6,28 @@ // sendOpReturn throw. function fakeWallet ({ cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', - utxos = [{ txid: 'utxo1' }], + utxos = [{ txid: 'utxo1', value: 100000 }], txid = 'fake-txid' } = {}) { const broadcasts = [] + const sends = [] const wallet = { walletInfo: { cashAddress }, + utxos, getUtxos: async () => utxos, - sendOpReturn: async function (msg, prefix) { - broadcasts.push({ msg, prefix }) + sendOpReturn: async function (msg, prefix, bchOutput = []) { + broadcasts.push({ msg, prefix, bchOutput }) + if (this.failWith) throw new Error(this.failWith) + return txid + }, + send: async function (receivers) { + sends.push(receivers) if (this.failWith) throw new Error(this.failWith) return txid } } wallet.broadcasts = broadcasts + wallet.sends = sends return wallet } diff --git a/test/unit/like-tip-page.test.js b/test/unit/like-tip-page.test.js new file mode 100644 index 0000000..45baff7 --- /dev/null +++ b/test/unit/like-tip-page.test.js @@ -0,0 +1,230 @@ +/* + Unit tests for the Like / Tip page behavior slice + (src/services/like-tip-page.js). + + These tests express the page-level behavior described by + specs/like-tip-memo.feature: + - opening the modal for a post checks the wallet balance. + - submitting a like without a tip broadcasts the Memo like action. + - submitting a like with a tip broadcasts the action and the tip. + - invalid, dust, maximum, and balance errors are surfaced. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const LikeTipPage = require('../../src/services/like-tip-page') +const MemoLike = require('../../src/services/memo-like') +const { fakeWallet } = require('../helpers/fake-wallet') + +const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + +function build () { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] }) + const feed = { posts: [], likes: [], addLike: (l) => feed.likes.push(l) } + const memoLike = new MemoLike({ wallet, feed }) + const page = new LikeTipPage({ memoLike }) + return { wallet, feed, memoLike, page } +} + +test('opening the modal sets the target post and author', () => { + const { page } = build() + + const result = page.open(POST_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, true) + assert.equal(page.modalOpen, true) + assert.equal(page.postTxid, POST_TXID) + assert.equal(page.authorAddress, AUTHOR_ADDRESS) +}) + +test('opening the modal with insufficient balance surfaces an add-BCH error', () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [] }) + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + + const result = page.open(POST_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_empty_balance') + assert.equal(page.submitError, 'like_empty_balance') + assert.match(page.broadcastError, /add BCH/i) + assert.equal(page.modalOpen, true) +}) + +test('opening the modal without a memo-like handler surfaces an error', () => { + const page = new LikeTipPage({}) + + const result = page.open(POST_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.match(page.broadcastError, /memo like/i) +}) + +test('closing the modal resets input and errors', () => { + const { page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('5000') + page.submitError = 'like_dust' + page.broadcastError = 'some error' + + page.close() + + assert.equal(page.modalOpen, false) + assert.equal(page.input, '') + assert.equal(page.submitError, null) + assert.equal(page.broadcastError, null) +}) + +test('submitting a pure like broadcasts the Memo like prefix', async () => { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(page.tipping, false) + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d04') + assert.deepEqual(wallet.broadcasts[0].bchOutput, []) +}) + +test('submitting a like with a tip broadcasts the prefix and the tip output', async () => { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('3000') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d04') + assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }]) +}) + +test('submitting a like on a post authored by the wallet works without a tip', async () => { + const { wallet, page } = build() + page.open(POST_TXID, MY_ADDRESS) + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d04') +}) + +test('submitting with a non-numeric tip is rejected', async () => { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('abc') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.equal(page.submitError, 'like_validation') + assert.match(page.broadcastError, /valid number/i) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('submitting with a decimal tip string is rejected', async () => { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('1.5') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.equal(wallet.broadcasts.length, 0) +}) + +test('submitting with a dust tip is rejected', async () => { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('2999') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_dust') + assert.equal(wallet.broadcasts.length, 0) +}) + +test('submitting with a tip above the maximum is rejected', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 150000000 }] }) + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('100000001') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_maximum') + assert.match(page.broadcastError, /maximum/i) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('submitting with a tip above the spendable balance is rejected', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 30000 }] }) + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip('35000') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_balance') + assert.match(page.broadcastError, /spendable/i) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('tipping flag is true while a submit is in flight and false once it settles', async () => { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + + let resolveSend + wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve }) + + assert.equal(page.tipping, false) + const pending = page.submit() + assert.equal(page.tipping, true) + + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(typeof resolveSend, 'function') + resolveSend('in-flight-txid') + await pending + assert.equal(page.tipping, false) +}) + +test('submitting without a memo-like handler reports an error', async () => { + const page = new LikeTipPage({}) + page.open(POST_TXID, AUTHOR_ADDRESS) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(page.submitError, 'broadcast') + assert.match(page.broadcastError, /memo like/i) +}) + +test('a failed broadcast surfaces the real error', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS }) + wallet.failWith = 'Insufficient balance' + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + page.open(POST_TXID, AUTHOR_ADDRESS) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(page.submitError, 'broadcast') + assert.match(page.broadcastError, /Insufficient balance/) +}) diff --git a/test/unit/memo-like.test.js b/test/unit/memo-like.test.js new file mode 100644 index 0000000..835e90c --- /dev/null +++ b/test/unit/memo-like.test.js @@ -0,0 +1,261 @@ +/* + Unit tests for the Memo like/tip behavior slice (src/services/memo-like.js). + + These tests express the observable behavior described by + specs/like-tip-memo.feature: + - a valid like broadcasts an OP_RETURN transaction carrying the Memo like + prefix (0x6d04) and the post txid bytes; an optional tip is included as a + BCH output to the author address. + - an invalid/non-integer tip is rejected with a validation error. + - a tip below the dust limit is rejected. + - a tip above the hard maximum is rejected. + - a tip above the spendable balance is rejected. + - a wallet without spendable balance cannot like. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const MemoLike = require('../../src/services/memo-like') +const { fakeWallet } = require('../helpers/fake-wallet') + +const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + +// A fake feed that records likes and exposes posts keyed by txid. +function fakeFeed (posts = []) { + const likes = [] + return { + posts, + likes, + addLike: (l) => likes.push(l) + } +} + +// Decode a like payload back into a 64-character hex txid. +function decodeLikeTxid (raw) { + return Buffer.from(raw).toString('hex') +} + +test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => { + assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04') +}) + +test('DUST_LIMIT_SATS is 3000', () => { + assert.equal(MemoLike.DUST_LIMIT_SATS, 3000) +}) + +test('MAX_TIP_SATS is 100000000', () => { + assert.equal(MemoLike.MAX_TIP_SATS, 100000000) +}) + +test('liking a post without a tip broadcasts an OP_RETURN with the Memo like prefix and txid', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS }) + const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 0 }]) + const memoLike = new MemoLike({ wallet, feed }) + + const txid = await memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) + const b = wallet.broadcasts[0] + assert.equal(b.prefix, '6d04') + assert.ok(b.msg instanceof Uint8Array) + assert.equal(decodeLikeTxid(b.msg), POST_TXID) + assert.deepEqual(b.bchOutput, []) + + // The feed reflects the like. + assert.equal(feed.likes.length, 1) + assert.equal(feed.likes[0].postTxid, POST_TXID) + assert.equal(feed.likes[0].address, MY_ADDRESS) + assert.equal(feed.likes[0].tipSats, 0) + assert.equal(feed.posts[0].likeCount, 1) +}) + +test('liking a post with a tip broadcasts an OP_RETURN and a tip output', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] }) + const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 5 }]) + const memoLike = new MemoLike({ wallet, feed }) + + const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) + const b = wallet.broadcasts[0] + assert.equal(b.prefix, '6d04') + assert.equal(decodeLikeTxid(b.msg), POST_TXID) + assert.deepEqual(b.bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }]) + + // The feed reflects the like. + assert.equal(feed.likes.length, 1) + assert.equal(feed.likes[0].tipSats, 3000) + assert.equal(feed.posts[0].likeCount, 6) +}) + +test('liking without a wallet reports a missing-wallet error', async () => { + const memoLike = new MemoLike({}) + + await assert.rejects( + memoLike.like(POST_TXID), + (err) => /wallet/i.test(err.message) + ) +}) + +test('liking with an invalid post txid reports a clear validation error', async () => { + const wallet = fakeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like('not-a-txid'), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('liking with a wrong-length but valid-hex post txid is rejected', async () => { + const wallet = fakeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like('a'.repeat(10)), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a non-integer tip like "1.5" is rejected with a validation error', async () => { + const wallet = fakeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, 1.5, AUTHOR_ADDRESS), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a non-numeric tip like "abc" is rejected with a validation error', async () => { + const wallet = fakeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, NaN, AUTHOR_ADDRESS), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a negative tip is rejected with a validation error', async () => { + const wallet = fakeWallet() + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, -1, AUTHOR_ADDRESS), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a tip below the dust limit is rejected with a dust error', async () => { + const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) + const memoLike = new MemoLike({ wallet }) + + for (const tip of [1, 2999]) { + await assert.rejects( + memoLike.like(POST_TXID, tip, AUTHOR_ADDRESS), + (err) => err.code === 'like_dust' + ) + } + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a tip at the dust limit is accepted', async () => { + const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) + const memoLike = new MemoLike({ wallet }) + + const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) +}) + +test('a tip above the hard maximum is rejected with a maximum error', async () => { + const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }) + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, 100000001, AUTHOR_ADDRESS), + (err) => err.code === 'like_maximum' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a tip above the spendable balance is rejected with a balance error', async () => { + const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }) + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, 35000, AUTHOR_ADDRESS), + (err) => err.code === 'like_balance' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a wallet with zero spendable balance cannot like', async () => { + const wallet = fakeWallet({ utxos: [] }) + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS), + (err) => err.code === 'like_empty_balance' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a wallet with balance below the dust limit cannot like', async () => { + const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }) + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS), + (err) => err.code === 'like_empty_balance' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('a pure like can be made on a post authored by the wallet address', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS }) + const feed = fakeFeed([{ txid: POST_TXID, addr: MY_ADDRESS, likeCount: 0 }]) + const memoLike = new MemoLike({ wallet, feed }) + + const txid = await memoLike.like(POST_TXID, 0, MY_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d04') + assert.equal(feed.posts[0].likeCount, 1) +}) + +test('a tip requires an author address', async () => { + const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) + const memoLike = new MemoLike({ wallet }) + + await assert.rejects( + memoLike.like(POST_TXID, 3000), + (err) => err.code === 'like_validation' + ) + assert.equal(wallet.broadcasts.length, 0) +}) + +test('getSpendableSats tolerates common utxo value field names', () => { + const walletValue = fakeWallet({ utxos: [{ txid: 'u1', value: 1000 }] }) + const walletSatoshis = fakeWallet({ utxos: [{ txid: 'u2', satoshis: 2000 }] }) + const walletAmount = fakeWallet({ utxos: [{ txid: 'u3', amount: 3000 }] }) + + assert.equal(new MemoLike({ wallet: walletValue }).getSpendableSats(), 1000) + assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000) + assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000) +}) From 6b7a7e2b863af94b134a239eff1725f12f2e5225 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 06:23:23 -0700 Subject: [PATCH 3/7] Reduce CRAP in like/tip slice and add property/coverage tests - Extract helpers in memo-like to bring all CRAP scores to 6 or below. - Consolidate duplicated rejection assertions in memo-like and like-tip-page tests. - Add hex unit tests to close a coverage gap. - Add property tests for hex round-trip, like validation, and spendable sum. By refactorer. --- src/services/memo-like.js | 66 ++++++++----- test/property/like-tip.property.test.js | 116 +++++++++++++++++++++++ test/unit/hex.test.js | 50 ++++++++++ test/unit/like-tip-page.test.js | 101 ++++++++++---------- test/unit/memo-like.test.js | 117 ++++++++---------------- 5 files changed, 303 insertions(+), 147 deletions(-) create mode 100644 test/property/like-tip.property.test.js create mode 100644 test/unit/hex.test.js diff --git a/src/services/memo-like.js b/src/services/memo-like.js index eb8ff60..63b0d9b 100644 --- a/src/services/memo-like.js +++ b/src/services/memo-like.js @@ -62,6 +62,19 @@ class MemoLike extends MemoAction { // Validate an optional tip amount against the dust limit, hard maximum, and // the wallet's spendable balance. validateTip (tipSats, spendableSats) { + this._validateTipAmount(tipSats) + + if (tipSats > spendableSats) { + const err = new Error('Tip exceeds the spendable balance.') + err.code = 'like_balance' + throw err + } + + return { ok: true } + } + + // Validate the tip amount's integer-ness, dust floor, and hard maximum. + _validateTipAmount (tipSats) { if (!Number.isInteger(tipSats) || tipSats < 0) { const err = new Error('Tip must be a valid number of satoshis.') err.code = 'like_validation' @@ -79,14 +92,6 @@ class MemoLike extends MemoAction { err.code = 'like_maximum' throw err } - - if (tipSats > spendableSats) { - const err = new Error('Tip exceeds the spendable balance.') - err.code = 'like_balance' - throw err - } - - return { ok: true } } // Sum the wallet's spendable UTXOs. Tolerates the common value field names @@ -121,17 +126,10 @@ class MemoLike extends MemoAction { } this.validateTip(tipSats, spendable) - - if (tipSats > 0 && (!authorAddress || typeof authorAddress !== 'string')) { - const err = new Error('Tip requires an author address.') - err.code = 'like_validation' - throw err - } + this._requireTipAddress(tipSats, authorAddress) const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid') - const bchOutput = tipSats > 0 - ? [{ address: authorAddress, amountSat: tipSats }] - : [] + const bchOutput = this._buildTipOutput(tipSats, authorAddress) const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput) @@ -142,6 +140,28 @@ class MemoLike extends MemoAction { // Record the new like on the injected feed when one is present. reflect (txid, postTxid, tipSats) { + this._notifyFeed(txid, postTxid, tipSats) + this._incrementPostCount(postTxid) + } + + // Require an author address whenever a tip is present. + _requireTipAddress (tipSats, authorAddress) { + if (tipSats <= 0) return + if (typeof authorAddress === 'string' && authorAddress.length > 0) return + const err = new Error('Tip requires an author address.') + err.code = 'like_validation' + throw err + } + + // Build the optional BCH tip output for the transaction. + _buildTipOutput (tipSats, authorAddress) { + return tipSats > 0 + ? [{ address: authorAddress, amountSat: tipSats }] + : [] + } + + // Notify the feed store of the new like when it exposes addLike. + _notifyFeed (txid, postTxid, tipSats) { if (this.feed && typeof this.feed.addLike === 'function') { this.feed.addLike({ txid, @@ -150,12 +170,14 @@ class MemoLike extends MemoAction { tipSats }) } + } - if (this.feed && Array.isArray(this.feed.posts)) { - const post = this.feed.posts.find((p) => p.txid === postTxid) - if (post) { - post.likeCount = (post.likeCount || 0) + 1 - } + // Increment the liked post's counter on the feed store when it is present. + _incrementPostCount (postTxid) { + if (!this.feed || !Array.isArray(this.feed.posts)) return + const post = this.feed.posts.find((p) => p.txid === postTxid) + if (post) { + post.likeCount = (post.likeCount || 0) + 1 } } } diff --git a/test/property/like-tip.property.test.js b/test/property/like-tip.property.test.js new file mode 100644 index 0000000..c919a23 --- /dev/null +++ b/test/property/like-tip.property.test.js @@ -0,0 +1,116 @@ +/* + Property tests for the Memo like / tip behavior slices. + + The like/tip slice centers on a post txid encoded as a 64-character hex + string. These tests assert useful invariants across a broad input range that + unit tests cover only at a few fixed points: + - hexToBytes round-trips any valid hex txid back to its canonical string. + - hexToBytes rejects any string that is not a valid 64-char hex txid. + - MemoLike.validate accepts any valid 64-char hex txid and rejects others. + - getSpendableSats conserves the sum of every spendable utxo value. +*/ + +'use strict' + +const test = require('node:test') + +const { forAll, seededRandom } = require('./harness') +const { fakeWallet } = require('../helpers/fake-wallet') + +const MemoLike = require('../../src/services/memo-like') +const { hexToBytes } = require('../../src/services/hex') + +const rng = seededRandom(20260720) + +// Build a random lowercase-hex string of the given byte length. +function hexString (bytes) { + const out = [] + for (let i = 0; i < bytes; i++) { + out.push(Math.floor(rng() * 256).toString(16).padStart(2, '0')) + } + return out.join('') +} + +test('hexToBytes round-trips a valid hex txid back to its canonical string', async () => { + await forAll( + () => hexString(32), + (hex) => Buffer.from(hexToBytes(hex, 32)).toString('hex') === hex, + { label: 'hexToBytes round-trip' } + ) +}) + +test('hexToBytes rejects any string that is not a 64-character hex txid', async () => { + await forAll( + (i) => { + const len = 1 + Math.floor(rng() * 100) + if (len !== 64) return 'z'.repeat(len) + // Exactly 64 chars but containing a non-hex character. + return `z${'a'.repeat(63)}` + }, + (input) => { + try { + hexToBytes(input, 32) + return false + } catch (err) { + return err instanceof Error + } + }, + { label: 'hexToBytes rejects invalid' } + ) +}) + +test('MemoLike.validate accepts any valid 64-character hex post txid', async () => { + await forAll( + () => hexString(32), + (txid) => { + const result = new MemoLike({}).validate(txid) + return result.ok === true + }, + { label: 'valid txid accepted' } + ) +}) + +test('MemoLike.validate rejects a non-hex post txid with like_validation', async () => { + await forAll( + () => { + // 64 chars drawn from g..z, guaranteed to be non-hex. + const chars = [] + for (let i = 0; i < 64; i++) { + chars.push(String.fromCharCode(103 + Math.floor(rng() * 20))) + } + return chars.join('') + }, + (txid) => { + try { + new MemoLike({}).validate(txid) + return false + } catch (err) { + return err.code === 'like_validation' + } + }, + { label: 'invalid txid rejected as like_validation' } + ) +}) + +test('getSpendableSats conserves the sum of every spendable utxo value', async () => { + await forAll( + (i) => { + const fields = ['value', 'satoshis', 'amount'] + const count = 1 + Math.floor(rng() * 8) + const utxos = [] + for (let k = 0; k < count; k++) { + utxos.push({ [fields[k % 3]]: Math.floor(rng() * 1000000) }) + } + return utxos + }, + (utxos) => { + const wallet = fakeWallet({ utxos }) + const expected = utxos.reduce( + (sum, u) => sum + (u.value ?? u.satoshis ?? u.amount ?? 0), + 0 + ) + return new MemoLike({ wallet }).getSpendableSats() === expected + }, + { label: 'spendable sum conservation' } + ) +}) diff --git a/test/unit/hex.test.js b/test/unit/hex.test.js new file mode 100644 index 0000000..c6145ad --- /dev/null +++ b/test/unit/hex.test.js @@ -0,0 +1,50 @@ +/* + Unit tests for the hex conversion helper (src/services/hex.js). + + Memo actions like replies and likes embed a post txid in the OP_RETURN + payload as raw bytes, so the helper must decode a canonical 64-character hex + string into exactly 32 bytes and reject anything else with a clear error. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { hexToBytes } = require('../../src/services/hex') + +test('hexToBytes decodes a 64-char hex string into 32 bytes', () => { + const hex = 'a'.repeat(64) + const bytes = hexToBytes(hex, 32) + assert.ok(bytes instanceof Uint8Array) + assert.equal(bytes.length, 32) + assert.equal(Buffer.from(bytes).toString('hex'), hex) +}) + +test('hexToBytes rejects a string of the wrong length', () => { + assert.throws( + () => hexToBytes('a'.repeat(10), 32), + /64-character hex string/ + ) +}) + +test('hexToBytes rejects a 64-char string containing a non-hex character', () => { + assert.throws( + () => hexToBytes(`z${'a'.repeat(63)}`, 32), + /valid hex string/ + ) +}) + +test('hexToBytes rejects non-string input', () => { + assert.throws( + () => hexToBytes(null, 32), + /64-character hex string/ + ) +}) + +test('hexToBytes uses a custom label in error messages', () => { + assert.throws( + () => hexToBytes('nope', 32, 'Post txid'), + /Post txid/ + ) +}) diff --git a/test/unit/like-tip-page.test.js b/test/unit/like-tip-page.test.js index 45baff7..39a033a 100644 --- a/test/unit/like-tip-page.test.js +++ b/test/unit/like-tip-page.test.js @@ -31,6 +31,23 @@ function build () { return { wallet, feed, memoLike, page } } +// Build a page with the given wallet balance, submit a tip string, and assert +// that the submit is rejected with the expected error code and message. +async function assertTipRejected (utxos, tip, expectedCode, messageRe) { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos }) + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip(tip) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, expectedCode) + assert.match(page.broadcastError, messageRe) + assert.equal(wallet.broadcasts.length, 0) +} + test('opening the modal sets the target post and author', () => { const { page } = build() @@ -118,30 +135,20 @@ test('submitting a like on a post authored by the wallet works without a tip', a assert.equal(wallet.broadcasts[0].prefix, '6d04') }) -test('submitting with a non-numeric tip is rejected', async () => { - const { wallet, page } = build() - page.open(POST_TXID, AUTHOR_ADDRESS) - page.setTip('abc') +test('submitting with a non-numeric tip string is rejected', async () => { + for (const tip of ['abc', '1.5']) { + const { wallet, page } = build() + page.open(POST_TXID, AUTHOR_ADDRESS) + page.setTip(tip) - const result = await page.submit() + const result = await page.submit() - assert.equal(result.ok, false) - assert.equal(result.error, 'like_validation') - assert.equal(page.submitError, 'like_validation') - assert.match(page.broadcastError, /valid number/i) - assert.equal(wallet.broadcasts.length, 0) -}) - -test('submitting with a decimal tip string is rejected', async () => { - const { wallet, page } = build() - page.open(POST_TXID, AUTHOR_ADDRESS) - page.setTip('1.5') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.equal(result.error, 'like_validation') - assert.equal(wallet.broadcasts.length, 0) + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.equal(page.submitError, 'like_validation') + assert.match(page.broadcastError, /valid number/i) + assert.equal(wallet.broadcasts.length, 0) + } }) test('submitting with a dust tip is rejected', async () => { @@ -157,33 +164,21 @@ test('submitting with a dust tip is rejected', async () => { }) test('submitting with a tip above the maximum is rejected', async () => { - const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 150000000 }] }) - const memoLike = new MemoLike({ wallet }) - const page = new LikeTipPage({ memoLike }) - page.open(POST_TXID, AUTHOR_ADDRESS) - page.setTip('100000001') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.equal(result.error, 'like_maximum') - assert.match(page.broadcastError, /maximum/i) - assert.equal(wallet.broadcasts.length, 0) + await assertTipRejected( + [{ txid: 'u1', value: 150000000 }], + '100000001', + 'like_maximum', + /maximum/i + ) }) test('submitting with a tip above the spendable balance is rejected', async () => { - const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 30000 }] }) - const memoLike = new MemoLike({ wallet }) - const page = new LikeTipPage({ memoLike }) - page.open(POST_TXID, AUTHOR_ADDRESS) - page.setTip('35000') - - const result = await page.submit() - - assert.equal(result.ok, false) - assert.equal(result.error, 'like_balance') - assert.match(page.broadcastError, /spendable/i) - assert.equal(wallet.broadcasts.length, 0) + await assertTipRejected( + [{ txid: 'u1', value: 30000 }], + '35000', + 'like_balance', + /spendable/i + ) }) test('tipping flag is true while a submit is in flight and false once it settles', async () => { @@ -228,3 +223,17 @@ test('a failed broadcast surfaces the real error', async () => { assert.equal(page.submitError, 'broadcast') assert.match(page.broadcastError, /Insufficient balance/) }) + +test('a submit failure with no error message surfaces the error name', async () => { + const wallet = fakeWallet({ cashAddress: MY_ADDRESS }) + const memoLike = new MemoLike({ wallet }) + memoLike.like = async () => { throw new Error('') } + const page = new LikeTipPage({ memoLike }) + page.open(POST_TXID, AUTHOR_ADDRESS) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(page.submitError, 'broadcast') + assert.equal(page.broadcastError, 'Error') +}) diff --git a/test/unit/memo-like.test.js b/test/unit/memo-like.test.js index 835e90c..6ea0341 100644 --- a/test/unit/memo-like.test.js +++ b/test/unit/memo-like.test.js @@ -35,11 +35,22 @@ function fakeFeed (posts = []) { } } -// Decode a like payload back into a 64-character hex txid. +// Decode a like payload back into the canonical 64-character hex txid. function decodeLikeTxid (raw) { return Buffer.from(raw).toString('hex') } +// Assert that a like with the given post txid and tip rejects with a specific +// error code and performs no broadcast. +async function assertLikeRejected (wallet, tip, expectedCode, postTxid = POST_TXID) { + const memoLike = new MemoLike({ wallet }) + await assert.rejects( + memoLike.like(postTxid, tip, AUTHOR_ADDRESS), + (err) => err.code === expectedCode + ) + assert.equal(wallet.broadcasts.length, 0) +} + test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => { assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04') }) @@ -105,71 +116,29 @@ test('liking without a wallet reports a missing-wallet error', async () => { }) test('liking with an invalid post txid reports a clear validation error', async () => { - const wallet = fakeWallet() - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like('not-a-txid'), - (err) => err.code === 'like_validation' - ) - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'not-a-txid') }) test('liking with a wrong-length but valid-hex post txid is rejected', async () => { - const wallet = fakeWallet() - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like('a'.repeat(10)), - (err) => err.code === 'like_validation' - ) - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'a'.repeat(10)) }) test('a non-integer tip like "1.5" is rejected with a validation error', async () => { - const wallet = fakeWallet() - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, 1.5, AUTHOR_ADDRESS), - (err) => err.code === 'like_validation' - ) - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(fakeWallet(), 1.5, 'like_validation') }) test('a non-numeric tip like "abc" is rejected with a validation error', async () => { - const wallet = fakeWallet() - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, NaN, AUTHOR_ADDRESS), - (err) => err.code === 'like_validation' - ) - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(fakeWallet(), NaN, 'like_validation') }) test('a negative tip is rejected with a validation error', async () => { - const wallet = fakeWallet() - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, -1, AUTHOR_ADDRESS), - (err) => err.code === 'like_validation' - ) - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(fakeWallet(), -1, 'like_validation') }) test('a tip below the dust limit is rejected with a dust error', async () => { const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) - const memoLike = new MemoLike({ wallet }) - - for (const tip of [1, 2999]) { - await assert.rejects( - memoLike.like(POST_TXID, tip, AUTHOR_ADDRESS), - (err) => err.code === 'like_dust' - ) - } - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(wallet, 1, 'like_dust') + await assertLikeRejected(wallet, 2999, 'like_dust') }) test('a tip at the dust limit is accepted', async () => { @@ -183,47 +152,31 @@ test('a tip at the dust limit is accepted', async () => { }) test('a tip above the hard maximum is rejected with a maximum error', async () => { - const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }) - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, 100000001, AUTHOR_ADDRESS), - (err) => err.code === 'like_maximum' + await assertLikeRejected( + fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }), + 100000001, + 'like_maximum' ) - assert.equal(wallet.broadcasts.length, 0) }) test('a tip above the spendable balance is rejected with a balance error', async () => { - const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }) - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, 35000, AUTHOR_ADDRESS), - (err) => err.code === 'like_balance' + await assertLikeRejected( + fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }), + 35000, + 'like_balance' ) - assert.equal(wallet.broadcasts.length, 0) }) test('a wallet with zero spendable balance cannot like', async () => { - const wallet = fakeWallet({ utxos: [] }) - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS), - (err) => err.code === 'like_empty_balance' - ) - assert.equal(wallet.broadcasts.length, 0) + await assertLikeRejected(fakeWallet({ utxos: [] }), 0, 'like_empty_balance') }) test('a wallet with balance below the dust limit cannot like', async () => { - const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }) - const memoLike = new MemoLike({ wallet }) - - await assert.rejects( - memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS), - (err) => err.code === 'like_empty_balance' + await assertLikeRejected( + fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }), + 0, + 'like_empty_balance' ) - assert.equal(wallet.broadcasts.length, 0) }) test('a pure like can be made on a post authored by the wallet address', async () => { @@ -259,3 +212,9 @@ test('getSpendableSats tolerates common utxo value field names', () => { assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000) assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000) }) + +test('getSpendableSats returns 0 without a wallet or spendable values', () => { + assert.equal(new MemoLike({}).getSpendableSats(), 0) + assert.equal(new MemoLike({ wallet: { utxos: undefined } }).getSpendableSats(), 0) + assert.equal(new MemoLike({ wallet: { utxos: [{ txid: 'u1' }] } }).getSpendableSats(), 0) +}) From 61d50dfa88d2a7a224ea374bf2d469a94761002a Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 06:40:27 -0700 Subject: [PATCH 4/7] Add architectural review for like-tip-memo - Add three boundary unit tests to memo-like (tip == max, spendable == dust, tip == spendable) closing mutation survivors. - Refresh mutate4javascript and acceptance-mutation manifests from normal tool runs. - Add written architectural review report for like-tip-memo. By architect. --- docs/reviews/like-tip-memo-summary.md | 75 +++++++++++++++++++++++++++ specs/like-tip-memo.feature | 4 ++ src/services/memo-like.js | 4 ++ test/unit/memo-like.test.js | 42 +++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 docs/reviews/like-tip-memo-summary.md diff --git a/docs/reviews/like-tip-memo-summary.md b/docs/reviews/like-tip-memo-summary.md new file mode 100644 index 0000000..ec38569 --- /dev/null +++ b/docs/reviews/like-tip-memo-summary.md @@ -0,0 +1,75 @@ +# Architectural Review Summary — like-tip-memo + +## Task and commits reviewed +- Task: `like-tip-memo` +- Processed the refactorer handoff `merge_and_process refactorer 6b7a7e2b86` and + fast-forward merged the `swarmforge-refactorer` branch ending at `6b7a7e2b86` + into `swarmforge-architect`, which carried: + - `eb9aa29` — specifier: added `specs/like-tip-memo.feature` (10 scenarios) and + `dev-docs/psf-memo-db-changes.md` + - `dd0535d` — coder: implemented the Memo like action (`0x6d04`) with optional + author tip, the like/tip page controller, heart-icon + modal UI, and acceptance + handlers + - `6b7a7e2b86` — refactorer: reduced CRAP in the like/tip slice, consolidated + rejection assertions, and added hex/like/property tests + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and +local code quality. + +1. **UI/Core separation (good).** Core behavior is cleanly split from UI: + - `src/services/memo-like.js` (extends `MemoAction`) owns compose/validate/broadcast + of the like OP_RETURN and optional tip, speaking only to an injected wallet. + - `src/services/like-tip-page.js` (extends `PageController`) owns the modal page + behavior (open/close/setTip/submit and error classification). + - React (`like-button.js`, `like-tip-modal.js`, `post-feed-item.js`) is a thin shell + over the testable services. Core is testable with no UI/IO. +2. **Dependency rule (good).** UI components depend inward on services; + `LikeTipPage` → `MemoLike` → `MemoAction`. Wallet/network I/O stays behind the + injected minimal-slp-wallet adapter. No low-level module reaches toward IO. +3. **Information hiding / encapsulation (good).** Txid hex encoding, tip validation + (integer, dust floor, hard max, spendable balance), and the `0x6d04` wire prefix + are encapsulated. `hexToBytes` was extracted to `src/services/hex.js` and is shared + with `memo-reply.js` — a good cross-slice DRY consolidation. +4. **Local code quality (good).** Small, single-responsibility modules; CRAP for every + like/tip function is at or below 6 with 100% coverage. +5. **Boundary test gap closed (architect fix).** Added three boundary unit tests to + `test/unit/memo-like.test.js`: + - a tip at exactly the hard maximum is accepted, + - a wallet with exactly the dust-limit balance can make a pure like, + - a tip at exactly the spendable balance is accepted. + These kill the previously-surviving `> -> >=` / `< -> <=` boundary mutants and pin + down the inclusive max/balance semantics. + +## Verification results +- **Unit (`node --test`):** 133/133 pass (including the 3 added boundary tests). +- **Property (`node --test test/property/*.test.js`):** 18/18 pass. +- **Acceptance (normal):** `memo-new`, `post-memo`, `set-name`, `reply-memo`, and + `like-tip-memo` (10 scenarios) generated suites all pass. +- **Mutation (`mutate4javascript src/services/memo-like.js --mutate-all`):** + 25 killed, 0 uncovered, **7 survived — all documented equivalents**: + - `validate` / `validateTip` `{ok:true}` returns are ignored by `like()` (dead + return value), + - `like` default `tipSats = 0` (callers always pass the parsed tip), + - `_requireTipAddress` / `_buildTipOutput` boundary mutations on `tipSats ≤ 1` / + `authorAddress.length > 1` are unreachable because such tips are below the dust + floor and rejected earlier. +- **DRY (`dry4javascript src/services/memo-like.js like-tip-page.js hex.js`):** no + duplicate candidates. +- **Gherkin acceptance mutation (soft) on `like-tip-memo.feature`:** 16 executed, + **4 killed, 12 survived**, 0 errors. Every survivor is an expected soft survival: + a mutated example value still yields the same asserted outcome (a still-invalid tip, + a still-below-dust tip, or a tip/balance pair that still trips the same check). +- **CRAP:** all like/tip functions ≤ 6.0 (max `_validateTipAmount` 6.0), 100% coverage. + +## Suite status +Unit + property + acceptance all pass; source-level mutation kills all meaningful +mutants with survivors documented as equivalents; DRY clean; CRAP within threshold. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `like-tip-memo`), to review + the architect commit (three boundary tests + tool-refreshed manifests; no source + logic change). +- No handoff to the specifier: the architect produced no functional feature commit. + +By architect. diff --git a/specs/like-tip-memo.feature b/specs/like-tip-memo.feature index 14d0065..466ef65 100644 --- a/specs/like-tip-memo.feature +++ b/specs/like-tip-memo.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-08-26T13:30:06.787347982Z","feature_name":"Like / Tip a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/like-tip-memo.feature","background_hash":"2cd08f817665556cd20cb9a69b0d96a8ad871a1a9c1929c9d7a56b41bb3eff64","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Like / Tip a Memo - 2 a pure like broadcasts the Memo like action","scenario_hash":"95a91e98202ff0980db0f249b801ed10b6e885ce24756116f90ef5d35289aa9d","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-08-26T13:30:06.787347982Z"},{"index":8,"name":"Like / Tip a Memo - 9 a user can like their own post","scenario_hash":"eec77e112d8aa86f9e8ff6b7e38634e0c252be14cb60c35974f71b07fead8fb5","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-08-26T13:30:06.787347982Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Like / Tip a Memo - 1, Like / Tip a Memo - 2, Like / Tip a Memo - 3, Like / Tip a Memo - 4, Like / Tip a Memo - 5, Like / Tip a Memo - 6, Like / Tip a Memo - 7, Like / Tip a Memo - 8, Like / Tip a Memo - 9, Like / Tip a Memo - 10 Feature: Like / Tip a Memo diff --git a/src/services/memo-like.js b/src/services/memo-like.js index 63b0d9b..78bb5fd 100644 --- a/src/services/memo-like.js +++ b/src/services/memo-like.js @@ -187,3 +187,7 @@ MemoLike.DUST_LIMIT_SATS = DUST_LIMIT_SATS MemoLike.MAX_TIP_SATS = MAX_TIP_SATS module.exports = MemoLike + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-26T13:36:49.209Z","module_hash":"f27440db63274125b6b03bafd66222b31966670d8956b865e0bf2c695aacb54b","functions":[{"id":"func/MemoLike.constructor","name":"MemoLike.constructor","line":42,"end_line":47,"hash":"6ebbf231548843e23238988fde00cca861fd89513abba00febba964f3ef32365"},{"id":"func/MemoLike.validate","name":"MemoLike.validate","line":51,"end_line":60,"hash":"d9267f14ac52c7aa3afc07333b621c5faa751efa217bf02ae7695bb640827d44"},{"id":"func/MemoLike.validateTip","name":"MemoLike.validateTip","line":64,"end_line":74,"hash":"504b41cfa8db9754d31b0698550f9fe791d005c5b44bd30ec380202f1df11d6f"},{"id":"func/MemoLike._validateTipAmount","name":"MemoLike._validateTipAmount","line":77,"end_line":95,"hash":"98483bcf6212c2e0a2400d4da1b667b9f1c017a06ee6e4f282ccdf71cb8e397a"},{"id":"func/MemoLike.getSpendableSats","name":"MemoLike.getSpendableSats","line":99,"end_line":106,"hash":"7790154b7489758e6254cc7be00802fde237a33e0bb1440a2da33d303a2bdbe7"},{"id":"func/MemoLike.like","name":"MemoLike.like","line":112,"end_line":139,"hash":"5a748442d77446643448f1835b5e546569524af642af6be7c338e33ca4901051"},{"id":"func/MemoLike.reflect","name":"MemoLike.reflect","line":142,"end_line":145,"hash":"b159b912ba5b88468e5bfb7458385edc932850fe43948f3028e8ed9fea7269e9"},{"id":"func/MemoLike._requireTipAddress","name":"MemoLike._requireTipAddress","line":148,"end_line":154,"hash":"3b838a583b14112523e2cbe856a63896b1045a1ad91dac500e38693332b7c890"},{"id":"func/MemoLike._buildTipOutput","name":"MemoLike._buildTipOutput","line":157,"end_line":161,"hash":"e2a602c0afa9730dee67497869c4d18ed75708f6503d68fb1f0c0ba596826b5a"},{"id":"func/MemoLike._notifyFeed","name":"MemoLike._notifyFeed","line":164,"end_line":173,"hash":"bfc0e888ee106e21ee73146e92ebd3e4ce41d2ed8595a1539f823dc6c8f31d06"},{"id":"func/MemoLike._incrementPostCount","name":"MemoLike._incrementPostCount","line":176,"end_line":182,"hash":"61ae383f836b64a7aeabaf0d04e50ac9a24b87f726d5d6afac5f5585cdc6031c"}]} +// mutate4javascript-manifest-end diff --git a/test/unit/memo-like.test.js b/test/unit/memo-like.test.js index 6ea0341..a7c0c77 100644 --- a/test/unit/memo-like.test.js +++ b/test/unit/memo-like.test.js @@ -159,6 +159,35 @@ test('a tip above the hard maximum is rejected with a maximum error', async () = ) }) +test('a tip at exactly the hard maximum is accepted', async () => { + const wallet = fakeWallet({ + cashAddress: MY_ADDRESS, + utxos: [{ txid: 'u1', value: MemoLike.MAX_TIP_SATS + 1000 }] + }) + const memoLike = new MemoLike({ wallet }) + + const txid = await memoLike.like(POST_TXID, MemoLike.MAX_TIP_SATS, AUTHOR_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) + assert.deepEqual(wallet.broadcasts[0].bchOutput, [ + { address: AUTHOR_ADDRESS, amountSat: MemoLike.MAX_TIP_SATS } + ]) +}) + +test('a wallet with exactly the dust-limit balance can make a pure like', async () => { + const wallet = fakeWallet({ + cashAddress: MY_ADDRESS, + utxos: [{ txid: 'u1', value: MemoLike.DUST_LIMIT_SATS }] + }) + const memoLike = new MemoLike({ wallet }) + + const txid = await memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) +}) + test('a tip above the spendable balance is rejected with a balance error', async () => { await assertLikeRejected( fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }), @@ -167,6 +196,19 @@ test('a tip above the spendable balance is rejected with a balance error', async ) }) +test('a tip at exactly the spendable balance is accepted', async () => { + const wallet = fakeWallet({ + cashAddress: MY_ADDRESS, + utxos: [{ txid: 'u1', value: 25000 }] + }) + const memoLike = new MemoLike({ wallet }) + + const txid = await memoLike.like(POST_TXID, 25000, AUTHOR_ADDRESS) + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) +}) + test('a wallet with zero spendable balance cannot like', async () => { await assertLikeRejected(fakeWallet({ utxos: [] }), 0, 'like_empty_balance') }) From 874a10992f3fe7c8aaac9e01df032fffd6656b05 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 06:49:15 -0700 Subject: [PATCH 5/7] Fix React hooks ordering in post feed item By specifier. --- src/components/post-feed/post-feed-item.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/post-feed/post-feed-item.js b/src/components/post-feed/post-feed-item.js index 16357d0..32be55d 100644 --- a/src/components/post-feed/post-feed-item.js +++ b/src/components/post-feed/post-feed-item.js @@ -31,12 +31,14 @@ function PostFeedItem ({ showLikeButton = true, embedded = false }) { - if (!post) return null - + // React hooks must be called unconditionally before any early return, so + // declare the like state first and guard the post access after. const [liked, setLiked] = useState(false) - const [likeCount, setLikeCount] = useState(post.likeCount || 0) + const [likeCount, setLikeCount] = useState(post?.likeCount || 0) const [showLikeModal, setShowLikeModal] = useState(false) + if (!post) return null + const displayName = getDisplayName(post.addr, profiles) const hasCustomName = Boolean( From e7e758d370be6f0ec32f9a3c53dee6c93ac4489d Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 07:02:53 -0700 Subject: [PATCH 6/7] Fix like/tip modal spendable balance against the live wallet The Like/Tip modal crashed with "utxos.reduce is not a function" because MemoLike.getSpendableSats assumed wallet.utxos was an array, while minimal-slp-wallet exposes it as a UtxoStore object whose spendable BCH outputs live under utxoStore.bchUtxos. Accept both shapes, and refresh the wallet UTXOs before the modal's gating balance check. By specifier. --- src/components/post-feed/like-tip-modal.js | 30 ++++++++++++++++++---- src/services/memo-like.js | 7 ++++- test/unit/memo-like.test.js | 24 +++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/components/post-feed/like-tip-modal.js b/src/components/post-feed/like-tip-modal.js index 4d0af8b..d6e4c5f 100644 --- a/src/components/post-feed/like-tip-modal.js +++ b/src/components/post-feed/like-tip-modal.js @@ -40,12 +40,32 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) setError('') setSubmitting(false) - const memoLike = new MemoLike({ wallet }) - const page = new LikeTipPage({ memoLike }) - const result = page.open(post.txid, post.addr) - if (!result.ok) { - setError(formatError(result)) + let cancelled = false + + const checkBalance = async () => { + try { + // Refresh the wallet's spendable UTXOs so the gating balance check is + // accurate against the live minimal-slp-wallet (which exposes + // wallet.utxos as a UtxoStore object, not an array). + if (typeof wallet.getUtxos === 'function') { + await wallet.getUtxos() + } + if (cancelled) return + + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + const result = page.open(post.txid, post.addr) + if (!result.ok) { + setError(formatError(result)) + } + } catch (err) { + if (!cancelled) setError(err.message) + } } + + checkBalance() + + return () => { cancelled = true } }, [show, post, wallet]) async function handleSubmit () { diff --git a/src/services/memo-like.js b/src/services/memo-like.js index 78bb5fd..038ccf2 100644 --- a/src/services/memo-like.js +++ b/src/services/memo-like.js @@ -98,7 +98,12 @@ class MemoLike extends MemoAction { // used by different wallet adapters. getSpendableSats () { if (!this.wallet) return 0 - const utxos = this.wallet.utxos || [] + // minimal-slp-wallet exposes wallet.utxos as a UtxoStore object whose + // spendable BCH outputs live under utxoStore.bchUtxos, while the test and + // acceptance wallets use a plain array. Accept both shapes. + const utxos = Array.isArray(this.wallet.utxos) + ? this.wallet.utxos + : (this.wallet.utxos?.utxoStore?.bchUtxos || []) return utxos.reduce((sum, u) => { const value = u.value ?? u.satoshis ?? u.amount ?? 0 return sum + value diff --git a/test/unit/memo-like.test.js b/test/unit/memo-like.test.js index a7c0c77..8030a0f 100644 --- a/test/unit/memo-like.test.js +++ b/test/unit/memo-like.test.js @@ -260,3 +260,27 @@ test('getSpendableSats returns 0 without a wallet or spendable values', () => { assert.equal(new MemoLike({ wallet: { utxos: undefined } }).getSpendableSats(), 0) assert.equal(new MemoLike({ wallet: { utxos: [{ txid: 'u1' }] } }).getSpendableSats(), 0) }) + +test('getSpendableSats reads spendable BCH outputs from the wallet UtxoStore object', () => { + // minimal-slp-wallet exposes wallet.utxos as a UtxoStore object whose + // spendable BCH outputs live under utxoStore.bchUtxos. + const utxoStore = { + utxoStore: { + bchUtxos: [ + { txid: 'u1', satoshis: 1500 }, + { txid: 'u2', value: 2000 }, + { txid: 'u3', satoshis: 2500 } + ], + slpUtxos: { type1: { tokens: [] }, nft: [] } + } + } + const wallet = fakeWallet({ utxos: utxoStore }) + + assert.equal(new MemoLike({ wallet }).getSpendableSats(), 6000) +}) + +test('getSpendableSats returns 0 for an empty UtxoStore object', () => { + const wallet = fakeWallet({ utxos: { utxoStore: { bchUtxos: [] } } }) + + assert.equal(new MemoLike({ wallet }).getSpendableSats(), 0) +}) From 962b2eb627957eb252b9779b53c532889378e595 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 07:10:06 -0700 Subject: [PATCH 7/7] Lower tip dust limit to 600 sats and keep the like/tip modal editable after errors Allow tips as low as 600 sats (tip output dust) while keeping the wallet balance gate at 3000 sats to cover transaction fees. Keep the tip input and Like button enabled after a validation error so the user can edit and retry. By specifier. --- specs/like-tip-memo.feature | 3 ++- src/components/post-feed/like-tip-modal.js | 10 +++++++--- src/services/memo-like.js | 10 +++++++--- test/unit/like-tip-page.test.js | 2 +- test/unit/memo-like.test.js | 15 +++++++++++---- 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/specs/like-tip-memo.feature b/specs/like-tip-memo.feature index 466ef65..40031f9 100644 --- a/specs/like-tip-memo.feature +++ b/specs/like-tip-memo.feature @@ -39,6 +39,7 @@ Feature: Like / Tip a Memo Examples: | tip | + | 600 | | 3000 | | 25000 | @@ -64,7 +65,7 @@ Feature: Like / Tip a Memo Examples: | tip | | 1 | - | 2999 | + | 599 | Scenario: Like / Tip a Memo - 6 a tip above the maximum is rejected Given the wallet has a spendable balance of 150000000 sats diff --git a/src/components/post-feed/like-tip-modal.js b/src/components/post-feed/like-tip-modal.js index d6e4c5f..073463d 100644 --- a/src/components/post-feed/like-tip-modal.js +++ b/src/components/post-feed/like-tip-modal.js @@ -123,8 +123,12 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) step='1' placeholder='0' value={tip} - onChange={(e) => setTip(e.target.value)} - disabled={submitting || !!error} + onChange={(e) => { + setTip(e.target.value) + // Clear a previous validation error so the user can retry. + setError('') + }} + disabled={submitting} /> @@ -141,7 +145,7 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) diff --git a/src/services/memo-like.js b/src/services/memo-like.js index 038ccf2..cff2127 100644 --- a/src/services/memo-like.js +++ b/src/services/memo-like.js @@ -16,7 +16,8 @@ Constants MEMO_LIKE_PREFIX : hex prefix for the Memo "like" action (0x6d04) - DUST_LIMIT_SATS : smallest non-dust BCH output (3000 sats) + DUST_LIMIT_SATS : minimum wallet balance required to broadcast a like (3000 sats) + DUST_TIP_SATS : smallest non-dust tip output (600 sats) MAX_TIP_SATS : sanity maximum for a single tip (100000000 sats = 1 BCH) PARENT_TXID_BYTES : liked post txid size in bytes (32) */ @@ -26,6 +27,7 @@ const { hexToBytes } = require('./hex') const MEMO_LIKE_PREFIX = '6d04' const DUST_LIMIT_SATS = 3000 +const DUST_TIP_SATS = 600 const MAX_TIP_SATS = 100000000 const PARENT_TXID_BYTES = 32 @@ -43,6 +45,7 @@ class MemoLike extends MemoAction { super(deps) this.feed = deps.feed this.dustLimit = deps.dustLimit || DUST_LIMIT_SATS + this.dustTipSats = deps.dustTipSats || DUST_TIP_SATS this.maxTip = deps.maxTip || MAX_TIP_SATS } @@ -81,8 +84,8 @@ class MemoLike extends MemoAction { throw err } - if (tipSats > 0 && tipSats < this.dustLimit) { - const err = new Error(`Tip is below the dust limit of ${this.dustLimit} sats.`) + if (tipSats > 0 && tipSats < this.dustTipSats) { + const err = new Error(`Tip is below the dust limit of ${this.dustTipSats} sats.`) err.code = 'like_dust' throw err } @@ -189,6 +192,7 @@ class MemoLike extends MemoAction { MemoLike.MEMO_LIKE_PREFIX = MEMO_LIKE_PREFIX MemoLike.DUST_LIMIT_SATS = DUST_LIMIT_SATS +MemoLike.DUST_TIP_SATS = DUST_TIP_SATS MemoLike.MAX_TIP_SATS = MAX_TIP_SATS module.exports = MemoLike diff --git a/test/unit/like-tip-page.test.js b/test/unit/like-tip-page.test.js index 39a033a..865ea7c 100644 --- a/test/unit/like-tip-page.test.js +++ b/test/unit/like-tip-page.test.js @@ -154,7 +154,7 @@ test('submitting with a non-numeric tip string is rejected', async () => { test('submitting with a dust tip is rejected', async () => { const { wallet, page } = build() page.open(POST_TXID, AUTHOR_ADDRESS) - page.setTip('2999') + page.setTip('599') const result = await page.submit() diff --git a/test/unit/memo-like.test.js b/test/unit/memo-like.test.js index 8030a0f..4667318 100644 --- a/test/unit/memo-like.test.js +++ b/test/unit/memo-like.test.js @@ -59,6 +59,10 @@ test('DUST_LIMIT_SATS is 3000', () => { assert.equal(MemoLike.DUST_LIMIT_SATS, 3000) }) +test('DUST_TIP_SATS is 600', () => { + assert.equal(MemoLike.DUST_TIP_SATS, 600) +}) + test('MAX_TIP_SATS is 100000000', () => { assert.equal(MemoLike.MAX_TIP_SATS, 100000000) }) @@ -135,20 +139,23 @@ test('a negative tip is rejected with a validation error', async () => { await assertLikeRejected(fakeWallet(), -1, 'like_validation') }) -test('a tip below the dust limit is rejected with a dust error', async () => { +test('a tip below the tip dust limit is rejected with a dust error', async () => { const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) await assertLikeRejected(wallet, 1, 'like_dust') - await assertLikeRejected(wallet, 2999, 'like_dust') + await assertLikeRejected(wallet, 599, 'like_dust') }) -test('a tip at the dust limit is accepted', async () => { +test('a tip at the tip dust limit is accepted', async () => { const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] }) const memoLike = new MemoLike({ wallet }) - const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS) + const txid = await memoLike.like(POST_TXID, 600, AUTHOR_ADDRESS) assert.equal(txid, 'fake-txid') assert.equal(wallet.broadcasts.length, 1) + assert.deepEqual(wallet.broadcasts[0].bchOutput, [ + { address: AUTHOR_ADDRESS, amountSat: 600 } + ]) }) test('a tip above the hard maximum is rejected with a maximum error', async () => {