mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge pull request #7 from Permissionless-Software-Foundation/tip
Tips & Likes
This commit is contained in:
+237
-3
@@ -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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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**.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -131,6 +131,7 @@ function RecentPosts (props) {
|
||||
key={post.txid}
|
||||
post={post}
|
||||
profiles={profiles}
|
||||
wallet={appData?.wallet}
|
||||
onReplyClick={() => openThread(post.txid)}
|
||||
showFooterMeta
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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'>
|
||||
<PostReplyCount
|
||||
count={post.replyCount ?? 0}
|
||||
onClick={onReplyClick}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user