Merge pull request #7 from Permissionless-Software-Foundation/tip

Tips & Likes
This commit is contained in:
Chris Troutner
2026-08-26 07:15:41 -07:00
committed by GitHub
21 changed files with 1846 additions and 32 deletions
+237 -3
View File
@@ -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 <parameter> 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.')
}
}
}
]
+63
View File
@@ -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=<address>` 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**.
+75
View File
@@ -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.
+117
View File
@@ -0,0 +1,117 @@
# 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
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 <txid> authored by the author address
When I click the heart icon on the post with txid <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 <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 <tip>
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 <tip> 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 |
| 600 |
| 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 <tip>
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 <tip>
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 |
| 599 |
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 <balance> sats
When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
When I enter a tip of <tip>
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 <balance> 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 <txid> authored by my address
When I click the heart icon on the post with txid <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 <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
+1
View File
@@ -131,6 +131,7 @@ function RecentPosts (props) {
key={post.txid}
post={post}
profiles={profiles}
wallet={appData?.wallet}
onReplyClick={() => openThread(post.txid)}
showFooterMeta
/>
+37
View File
@@ -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 (
<button
type='button'
className={className}
aria-label={label}
title={label}
onClick={onClick}
>
<FontAwesomeIcon icon={icon} className='post-like-button-icon' />
<span className='post-like-button-count'>{count}</span>
</button>
)
}
export default LikeButton
+157
View File
@@ -0,0 +1,157 @@
/*
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)
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 () {
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 (
<Modal show={show} onHide={handleCancel} centered>
<Modal.Header closeButton>
<Modal.Title>Like / Tip</Modal.Title>
</Modal.Header>
<Modal.Body>
{post && (
<p className='like-tip-modal-target'>
Like the post by <strong>{displayName}</strong>
</p>
)}
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
<Form.Group controlId='like-tip-amount' className='mb-3'>
<Form.Label>Tip (satoshis, optional)</Form.Label>
<Form.Control
type='number'
min='0'
step='1'
placeholder='0'
value={tip}
onChange={(e) => {
setTip(e.target.value)
// Clear a previous validation error so the user can retry.
setError('')
}}
disabled={submitting}
/>
</Form.Group>
</Form>
{error && (
<p className='like-tip-modal-error text-danger'>{error}</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant='secondary' onClick={handleCancel}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleSubmit}
disabled={submitting || !post || !wallet}
>
{submitting ? 'Liking...' : 'Like'}
</Button>
</Modal.Footer>
</Modal>
)
}
export default LikeTipModal
+44 -2
View File
@@ -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,12 +23,20 @@ function PostFeedItem ({
post,
profile = {},
profiles = {},
wallet,
onReplyClick,
showRepliedLabel = false,
showFooterMeta = false,
showReplyCount = true,
showLikeButton = true,
embedded = false
}) {
// 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 [showLikeModal, setShowLikeModal] = useState(false)
if (!post) return null
const displayName = getDisplayName(post.addr, profiles)
@@ -46,6 +56,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 (
<Wrapper
className={[
@@ -117,12 +141,21 @@ function PostFeedItem ({
</p>
</div>
{showReplyCount && (
{(showReplyCount || showLikeButton) && (
<div className='posts-feed-item-actions'>
{showLikeButton && (
<LikeButton
count={likeCount}
liked={liked}
onClick={handleLikeClick}
/>
)}
{showReplyCount && (
<PostReplyCount
count={post.replyCount ?? 0}
onClick={onReplyClick}
/>
)}
</div>
)}
@@ -147,6 +180,15 @@ function PostFeedItem ({
</button>
</footer>
)}
<LikeTipModal
show={showLikeModal}
post={post}
wallet={wallet}
profiles={profiles}
onHide={handleLikeModalHide}
onSuccess={handleLikeSuccess}
/>
</Wrapper>
)
}
+59
View File
@@ -603,3 +603,62 @@
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;
}
+2 -1
View File
@@ -107,7 +107,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil
{!loading && !error && thread && (
<>
<PostThreadNode post={thread} profiles={profiles} isRoot />
<PostThreadNode post={thread} profiles={profiles} wallet={wallet} isRoot />
<ReplyThreadForm
parentTxid={txid}
rootPost={thread}
@@ -120,6 +120,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil
key={reply.txid}
post={reply}
profiles={profiles}
wallet={wallet}
/>
))}
</>
@@ -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}
/>
))}
+28
View File
@@ -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 }
+106
View File
@@ -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
+202
View File
@@ -0,0 +1,202 @@
/*
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 : 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)
*/
const MemoAction = require('./memo-action')
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
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.dustTipSats = deps.dustTipSats || DUST_TIP_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) {
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'
throw err
}
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
}
if (tipSats > this.maxTip) {
const err = new Error(`Tip exceeds the maximum of ${this.maxTip} sats.`)
err.code = 'like_maximum'
throw err
}
}
// Sum the wallet's spendable UTXOs. Tolerates the common value field names
// used by different wallet adapters.
getSpendableSats () {
if (!this.wallet) return 0
// 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
}, 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)
this._requireTipAddress(tipSats, authorAddress)
const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
const bchOutput = this._buildTipOutput(tipSats, authorAddress)
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) {
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,
postTxid,
address: this.wallet.walletInfo.cashAddress,
tipSats
})
}
}
// 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
}
}
}
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
// 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
+2 -17
View File
@@ -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: <parent txid 32 bytes><reply text UTF-8 bytes>.
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
-1
View File
@@ -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
+11 -3
View File
@@ -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
}
+116
View File
@@ -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' }
)
})
+50
View File
@@ -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/
)
})
+239
View File
@@ -0,0 +1,239 @@
/*
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 }
}
// 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()
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 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()
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 () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('599')
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 () => {
await assertTipRejected(
[{ txid: 'u1', value: 150000000 }],
'100000001',
'like_maximum',
/maximum/i
)
})
test('submitting with a tip above the spendable balance is rejected', async () => {
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 () => {
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/)
})
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')
})
+293
View File
@@ -0,0 +1,293 @@
/*
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 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')
})
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)
})
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 () => {
await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'not-a-txid')
})
test('liking with a wrong-length but valid-hex post txid is rejected', async () => {
await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'a'.repeat(10))
})
test('a non-integer tip like "1.5" is rejected with a validation error', async () => {
await assertLikeRejected(fakeWallet(), 1.5, 'like_validation')
})
test('a non-numeric tip like "abc" is rejected with a validation error', async () => {
await assertLikeRejected(fakeWallet(), NaN, 'like_validation')
})
test('a negative tip is rejected with a validation error', async () => {
await assertLikeRejected(fakeWallet(), -1, 'like_validation')
})
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, 599, 'like_dust')
})
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, 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 () => {
await assertLikeRejected(
fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }),
100000001,
'like_maximum'
)
})
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 }] }),
35000,
'like_balance'
)
})
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')
})
test('a wallet with balance below the dust limit cannot like', async () => {
await assertLikeRejected(
fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }),
0,
'like_empty_balance'
)
})
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)
})
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)
})
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)
})