diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index d2b7ef0..e2eaba8 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -26,10 +26,16 @@ const ReplyThreadPage = require('../../src/services/reply-thread-page') const MemoSetName = require('../../src/services/memo-set-name') const SetNamePage = require('../../src/services/set-name-page') const AccountPage = require('../../src/services/account-page') +const MemoLike = require('../../src/services/memo-like') +const LikeTipPage = require('../../src/services/like-tip-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX +const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX + +// Default author address used by Gherkin steps that refer to "the author address". +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' // A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. function makeWallet (address) { @@ -40,9 +46,9 @@ function makeWallet (address) { getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (msg, prefix) { + sendOpReturn: async function (msg, prefix, bchOutput = []) { // Record the broadcast attempt, then fail if configured to do so. - this.broadcasts.push({ msg, prefix }) + this.broadcasts.push({ msg, prefix, bchOutput }) if (this.failWith) throw new Error(this.failWith) return 'aa'.repeat(32) } @@ -86,14 +92,18 @@ function createWorld () { const memoPost = new MemoPost({ wallet, feed }) const thread = makeThread() const memoReply = new MemoReply({ wallet, thread }) + const memoLike = new MemoLike({ wallet, feed }) + const world = { wallet, feed, thread, memoPost, memoReply, + memoLike, currentPath: null, - menuOpen: false + menuOpen: false, + likedTxids: new Set() } // The New Post Page controller wraps the memo post behavior. Its navigate @@ -111,6 +121,9 @@ function createWorld () { navigate: () => {} }) + // The Like / Tip Page controller wraps the memo like behavior. + world.likeTipPage = new LikeTipPage({ memoLike }) + // The Set Name Page and Account Page controllers share a profile store so // a name set on one page is visible on the other. const profiles = makeProfiles() @@ -136,6 +149,24 @@ function decodeReplyPayload (raw) { return { parentTxid, text } } +// Decode a raw like payload back into the liked post txid (hex). +function decodeLikeTxid (raw) { + return Buffer.from(raw).toString('hex') +} + +// Resolve a literal value or a placeholder from the example store. +function resolveParam (value, example) { + const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim()) + if (match) { + const param = match[1] + if (!(param in example)) { + throw new Error(`Missing example value for "${param}"`) + } + return example[param] + } + return String(value).trim() +} + // Handler registry. Each entry: { pattern, run }. // run receives (match, exampleStore, world, step). const handlers = [ @@ -562,6 +593,209 @@ const handlers = [ throw new Error('Account page does not show a Set Name button.') } } + }, + { + name: 'wallet has spendable balance', + pattern: /^the wallet has a spendable balance of (.+) sats$/, + run (m, example, world) { + const balance = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(balance)) { + throw new Error(`Invalid balance value "${m[1]}"`) + } + world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }] + } + }, + { + name: 'post with txid authored by author address', + pattern: /^a post with the txid (.+) authored by the author address$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = { + txid, + addr: AUTHOR_ADDRESS, + address: AUTHOR_ADDRESS, + text: 'A sample post', + likeCount: 0 + } + world.feed.addPost(post) + } + }, + { + name: 'post with txid authored by my address', + pattern: /^a post with the txid (.+) authored by my address$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const myAddress = world.wallet.walletInfo.cashAddress + const post = { + txid, + addr: myAddress, + address: myAddress, + text: 'My own post', + likeCount: 0 + } + world.feed.addPost(post) + } + }, + { + name: 'click heart icon on post', + pattern: /^I click the heart icon on the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = world.feed.posts.find((p) => p.txid === txid) + const authorAddress = post ? post.addr : AUTHOR_ADDRESS + world.likeTipPage.open(txid, authorAddress) + } + }, + { + name: 'like/tip modal opens for post', + pattern: /^a like\/tip modal opens for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + if (!world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to be open.') + } + if (world.likeTipPage.postTxid !== txid) { + throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`) + } + } + }, + { + name: 'submit like without tip', + pattern: /^I submit the like without a tip$/, + async run (m, example, world) { + world.likeTipPage.setTip('') + const result = await world.likeTipPage.submit() + if (result.ok) { + world.likedTxids.add(world.likeTipPage.postTxid) + } + } + }, + { + name: 'enter tip', + pattern: /^I enter a tip of (.+)$/, + run (m, example, world) { + world.likeTipPage.setTip(resolveParam(m[1], example)) + } + }, + { + name: 'submit like', + pattern: /^I submit the like$/, + async run (m, example, world) { + const result = await world.likeTipPage.submit() + if (result.ok) { + world.likedTxids.add(world.likeTipPage.postTxid) + } + } + }, + { + name: 'broadcasts OP_RETURN with Memo like prefix', + pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No OP_RETURN transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (last.prefix !== MEMO_LIKE_PREFIX) { + throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`) + } + if (decodeLikeTxid(last.msg) !== txid) { + throw new Error(`Broadcast liked txid did not match ${txid}.`) + } + } + }, + { + name: 'wallet sends no tip', + pattern: /^the wallet sends no tip$/, + run (m, example, world) { + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) { + throw new Error('Expected no tip output, but one was present.') + } + } + }, + { + name: 'wallet sends tip to author', + pattern: /^the wallet sends a tip of (.+) to the author address$/, + run (m, example, world) { + const expectedTip = parseInt(resolveParam(m[1], example), 10) + if (Number.isNaN(expectedTip)) { + throw new Error(`Invalid tip value "${m[1]}"`) + } + const broadcasts = world.wallet.broadcasts + if (!broadcasts.length) { + throw new Error('No transaction was broadcast.') + } + const last = broadcasts[broadcasts.length - 1] + if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) { + throw new Error('Expected a tip output, but none was present.') + } + const tipOutput = last.bchOutput[0] + if (tipOutput.amountSat !== expectedTip) { + throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`) + } + const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid) + const expectedAddress = post ? post.addr : AUTHOR_ADDRESS + if (tipOutput.address !== expectedAddress) { + throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`) + } + } + }, + { + name: 'like count increases by one', + pattern: /^the like count on the post increases by one$/, + run (m, example, world) { + const postTxid = world.likeTipPage.postTxid + const post = world.feed.posts.find((p) => p.txid === postTxid) + if (!post) { + throw new Error(`Post ${postTxid} not found in feed.`) + } + if (post.likeCount !== 1) { + throw new Error(`Expected like count to be 1, got ${post.likeCount}.`) + } + } + }, + { + name: 'heart icon shows as filled', + pattern: /^the heart icon on the post shows as filled$/, + run (m, example, world) { + const postTxid = world.likeTipPage.postTxid + if (!world.likedTxids.has(postTxid)) { + throw new Error(`Expected heart icon to be filled for ${postTxid}.`) + } + } + }, + { + name: 'like/tip modal shows error containing text', + pattern: /^the like\/tip modal shows an error containing "(.+)"$/, + run (m, example, world) { + const expected = m[1] + const actual = world.likeTipPage.broadcastError || '' + if (!actual.includes(expected)) { + throw new Error(`Expected an error containing "${expected}", got "${actual}".`) + } + } + }, + { + name: 'click cancel button', + pattern: /^I click the cancel button$/, + run (m, example, world) { + world.likeTipPage.close() + } + }, + { + name: 'like/tip modal closes', + pattern: /^the like\/tip modal closes$/, + run (m, example, world) { + if (world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to be closed.') + } + } } ] diff --git a/dev-docs/psf-memo-db-changes.md b/dev-docs/psf-memo-db-changes.md new file mode 100644 index 0000000..136525c --- /dev/null +++ b/dev-docs/psf-memo-db-changes.md @@ -0,0 +1,63 @@ +# psf-memo-db changes to support Like counts + +**Status**: DRAFT — notes for a future session. The current Like/Tip feature +(`0x6d04`) focuses on the **UI to broadcast a like** (and an optional tip). The +read-side changes below are **not** implemented now; they are recorded here so the +like-count read path can be developed later. + +Owner: specifier. +Last updated: 2026-08-26. + +--- + +## Goal + +Expose like counts (and, later, liked-state and a likers list) so the psf-memo-client +UI can show a real like count on each post and whether the viewing user already liked +it. Today `/posts/*` responses omit likes entirely. + +## What the indexer already provides + +The Memo **indexer** (`psf-memo-indexer`) already parses `0x6d04` like/tip actions into +the DB as social references: a **liker address** → a **liked post txid** (with an +optional tip value). This feature does not require indexer changes to record likes; it +requires the **DB query/API** layer to aggregate and expose them. + +## Required psf-memo-db changes + +1. **`likeCount` on post responses.** + Add a `likeCount` field (number of distinct `0x6d04` references whose liked txid + equals the post txid) to the objects returned by: + - `/posts/recent` (feed items) + - `/post/:txid` (thread root) + - thread reply nodes (when replies are also likeable / shown with counts) + Aggregate the count in the query rather than an N+1 per-post lookup. + +2. **Liked-state for the viewing user (optional, later).** + To render a filled heart when the current wallet has already liked a post, the + read endpoints need to know the viewer. Add an optional `viewer=
` query + param (or equivalent) to the relevant post endpoints and return + `liked: true|false` per post based on whether `viewer` has a `0x6d04` reference to + that txid. Until this exists, the client can track "liked" locally/optimistically + for the current session only. + +3. **Likers list endpoint (later).** + memo.cash shows a modal listing who liked a post (its `post/likes`). Add an endpoint + e.g. `GET /post/:txid/likes` returning `[{ address, name, profilePicUrl, tip }...]` + for the addresses that liked the post, ordered by time/tip, joined with the profile + store to avoid N+1 lookups. Used by a future "likes" modal. + +4. **Join efficiency.** + Like counts must be aggregated server-side (e.g. a counter derived from the + reference index or a materialized count) and included in the same response as the + post text, author, name, and avatar — avoid N+1 per-item like lookups in feed and + thread responses. + +## Out of scope (this session) + +- The like-count **read surface** (count badge, liked-state, likers modal). +- `viewer` liked-state param. +- Likers list endpoint. + +All of the above are future work; the UI spec for this session only broadcasts the +like/tip and increments a count **optimistically**. diff --git a/docs/reviews/like-tip-memo-summary.md b/docs/reviews/like-tip-memo-summary.md new file mode 100644 index 0000000..ec38569 --- /dev/null +++ b/docs/reviews/like-tip-memo-summary.md @@ -0,0 +1,75 @@ +# Architectural Review Summary — like-tip-memo + +## Task and commits reviewed +- Task: `like-tip-memo` +- Processed the refactorer handoff `merge_and_process refactorer 6b7a7e2b86` and + fast-forward merged the `swarmforge-refactorer` branch ending at `6b7a7e2b86` + into `swarmforge-architect`, which carried: + - `eb9aa29` — specifier: added `specs/like-tip-memo.feature` (10 scenarios) and + `dev-docs/psf-memo-db-changes.md` + - `dd0535d` — coder: implemented the Memo like action (`0x6d04`) with optional + author tip, the like/tip page controller, heart-icon + modal UI, and acceptance + handlers + - `6b7a7e2b86` — refactorer: reduced CRAP in the like/tip slice, consolidated + rejection assertions, and added hex/like/property tests + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and +local code quality. + +1. **UI/Core separation (good).** Core behavior is cleanly split from UI: + - `src/services/memo-like.js` (extends `MemoAction`) owns compose/validate/broadcast + of the like OP_RETURN and optional tip, speaking only to an injected wallet. + - `src/services/like-tip-page.js` (extends `PageController`) owns the modal page + behavior (open/close/setTip/submit and error classification). + - React (`like-button.js`, `like-tip-modal.js`, `post-feed-item.js`) is a thin shell + over the testable services. Core is testable with no UI/IO. +2. **Dependency rule (good).** UI components depend inward on services; + `LikeTipPage` → `MemoLike` → `MemoAction`. Wallet/network I/O stays behind the + injected minimal-slp-wallet adapter. No low-level module reaches toward IO. +3. **Information hiding / encapsulation (good).** Txid hex encoding, tip validation + (integer, dust floor, hard max, spendable balance), and the `0x6d04` wire prefix + are encapsulated. `hexToBytes` was extracted to `src/services/hex.js` and is shared + with `memo-reply.js` — a good cross-slice DRY consolidation. +4. **Local code quality (good).** Small, single-responsibility modules; CRAP for every + like/tip function is at or below 6 with 100% coverage. +5. **Boundary test gap closed (architect fix).** Added three boundary unit tests to + `test/unit/memo-like.test.js`: + - a tip at exactly the hard maximum is accepted, + - a wallet with exactly the dust-limit balance can make a pure like, + - a tip at exactly the spendable balance is accepted. + These kill the previously-surviving `> -> >=` / `< -> <=` boundary mutants and pin + down the inclusive max/balance semantics. + +## Verification results +- **Unit (`node --test`):** 133/133 pass (including the 3 added boundary tests). +- **Property (`node --test test/property/*.test.js`):** 18/18 pass. +- **Acceptance (normal):** `memo-new`, `post-memo`, `set-name`, `reply-memo`, and + `like-tip-memo` (10 scenarios) generated suites all pass. +- **Mutation (`mutate4javascript src/services/memo-like.js --mutate-all`):** + 25 killed, 0 uncovered, **7 survived — all documented equivalents**: + - `validate` / `validateTip` `{ok:true}` returns are ignored by `like()` (dead + return value), + - `like` default `tipSats = 0` (callers always pass the parsed tip), + - `_requireTipAddress` / `_buildTipOutput` boundary mutations on `tipSats ≤ 1` / + `authorAddress.length > 1` are unreachable because such tips are below the dust + floor and rejected earlier. +- **DRY (`dry4javascript src/services/memo-like.js like-tip-page.js hex.js`):** no + duplicate candidates. +- **Gherkin acceptance mutation (soft) on `like-tip-memo.feature`:** 16 executed, + **4 killed, 12 survived**, 0 errors. Every survivor is an expected soft survival: + a mutated example value still yields the same asserted outcome (a still-invalid tip, + a still-below-dust tip, or a tip/balance pair that still trips the same check). +- **CRAP:** all like/tip functions ≤ 6.0 (max `_validateTipAmount` 6.0), 100% coverage. + +## Suite status +Unit + property + acceptance all pass; source-level mutation kills all meaningful +mutants with survivors documented as equivalents; DRY clean; CRAP within threshold. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `like-tip-memo`), to review + the architect commit (three boundary tests + tool-refreshed manifests; no source + logic change). +- No handoff to the specifier: the architect produced no functional feature commit. + +By architect. diff --git a/specs/like-tip-memo.feature b/specs/like-tip-memo.feature new file mode 100644 index 0000000..40031f9 --- /dev/null +++ b/specs/like-tip-memo.feature @@ -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 authored by the author address + When I click the heart icon on the post with txid + When I submit the like without a tip + Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid + Then the wallet sends no tip + Then the like count on the post increases by one + Then the heart icon on the post shows as filled + + Examples: + | txid | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | + | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | + + Scenario Outline: Like / Tip a Memo - 3 a like with a tip broadcasts and pays the author + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then the wallet sends a tip of to the author address + Then the like count on the post increases by one + Then the heart icon on the post shows as filled + + Examples: + | tip | + | 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 + When I submit the like + Then the like/tip modal shows an error containing "valid number" + Then the wallet does not broadcast any transaction + + Examples: + | tip | + | 1.5 | + | abc | + + Scenario Outline: Like / Tip a Memo - 5 a tip below the dust limit is rejected + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the like/tip modal shows an error containing "dust limit" + Then the wallet does not broadcast any transaction + + Examples: + | tip | + | 1 | + | 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 sats + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I enter a tip of + When I submit the like + Then the like/tip modal shows an error containing "spendable" + Then the wallet does not broadcast any transaction + + Examples: + | balance | tip | + | 30000 | 35000 | + | 500000 | 550000 | + + Scenario Outline: Like / Tip a Memo - 8 a user without spendable balance cannot like + Given the wallet has a spendable balance of sats + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then the like/tip modal shows an error containing "add BCH" + Then the wallet does not broadcast any transaction + + Examples: + | balance | + | 0 | + | 2999 | + + Scenario Outline: Like / Tip a Memo - 9 a user can like their own post + Given a post with the txid authored by my address + When I click the heart icon on the post with txid + When I submit the like without a tip + Then the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid + Then the like count on the post increases by one + + Examples: + | txid | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | + | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | + + Scenario: Like / Tip a Memo - 10 the cancel button closes the like/tip modal + When I click the heart icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + When I click the cancel button + Then the like/tip modal closes diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index 3172b0a..eee1fe8 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -131,6 +131,7 @@ function RecentPosts (props) { key={post.txid} post={post} profiles={profiles} + wallet={appData?.wallet} onReplyClick={() => openThread(post.txid)} showFooterMeta /> diff --git a/src/components/post-feed/like-button.js b/src/components/post-feed/like-button.js new file mode 100644 index 0000000..ff28eb7 --- /dev/null +++ b/src/components/post-feed/like-button.js @@ -0,0 +1,37 @@ +/* + Like button for a post (heart icon + count). + + The icon is filled when the post has been liked in the current session and + outlined otherwise. The count is displayed next to the icon. +*/ + +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons' +import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons' + +import './post-feed.css' + +function LikeButton ({ count = 0, liked = false, onClick }) { + const label = count === 1 ? '1 like' : `${count} likes` + const icon = liked ? faHeartSolid : faHeartRegular + const className = [ + 'post-like-button', + liked ? 'post-like-button-liked' : '' + ].filter(Boolean).join(' ') + + return ( + + ) +} + +export default LikeButton diff --git a/src/components/post-feed/like-tip-modal.js b/src/components/post-feed/like-tip-modal.js new file mode 100644 index 0000000..073463d --- /dev/null +++ b/src/components/post-feed/like-tip-modal.js @@ -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 ( + + + Like / Tip + + + + {post && ( +

+ Like the post by {displayName} +

+ )} + +
{ e.preventDefault(); handleSubmit() }}> + + Tip (satoshis, optional) + { + setTip(e.target.value) + // Clear a previous validation error so the user can retry. + setError('') + }} + disabled={submitting} + /> + +
+ + {error && ( +

{error}

+ )} +
+ + + + + +
+ ) +} + +export default LikeTipModal diff --git a/src/components/post-feed/post-feed-item.js b/src/components/post-feed/post-feed-item.js index 2f1aa3b..32be55d 100644 --- a/src/components/post-feed/post-feed-item.js +++ b/src/components/post-feed/post-feed-item.js @@ -2,12 +2,14 @@ Instagram-style post card for feed and thread views. */ -import React from 'react' +import React, { useState } from 'react' import { Link } from 'react-router-dom' import AppUtil from '../../util' import PostReplyCount from '../post-reply-count' import PostThreadAvatar from '../post-thread-modal/post-thread-avatar' +import LikeButton from './like-button' +import LikeTipModal from './like-tip-modal' import { formatRelativeSeen, getDisplayName, @@ -21,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 ( - {showReplyCount && ( + {(showReplyCount || showLikeButton) && (
- + {showLikeButton && ( + + )} + {showReplyCount && ( + + )}
)} @@ -147,6 +180,15 @@ function PostFeedItem ({ )} + +
) } diff --git a/src/components/post-feed/post-feed.css b/src/components/post-feed/post-feed.css index e31cc51..96009ea 100644 --- a/src/components/post-feed/post-feed.css +++ b/src/components/post-feed/post-feed.css @@ -602,4 +602,63 @@ .recent-posts-pagination { padding: 0 0.75rem; } +} + +.post-like-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + + min-height: 32px; + padding: 4px 0; + + color: var(--ig-text); + background: transparent; + + border: 0; + border-radius: 0; + + font-size: 13px; + font-weight: 600; + line-height: 18px; + text-decoration: none; + + cursor: pointer; + + transition: + opacity 0.15s ease, + color 0.15s ease; +} + +.post-like-button:hover { + color: var(--ig-danger); + background: transparent; + border: 0; + box-shadow: none; + transform: none; + opacity: 0.65; +} + +.post-like-button-liked, +.post-like-button-liked:hover { + color: var(--ig-danger); + opacity: 1; +} + +.post-like-button-icon { + font-size: 18px; +} + +.post-like-button-count { + min-width: 1.2em; +} + +.like-tip-modal-target { + margin-bottom: 1rem; +} + +.like-tip-modal-error { + margin: 0; + font-size: 0.95rem; } \ No newline at end of file diff --git a/src/components/post-thread-modal/index.js b/src/components/post-thread-modal/index.js index f359540..6305037 100644 --- a/src/components/post-thread-modal/index.js +++ b/src/components/post-thread-modal/index.js @@ -107,7 +107,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil {!loading && !error && thread && ( <> - + ))} diff --git a/src/components/post-thread-modal/post-thread-node.js b/src/components/post-thread-modal/post-thread-node.js index b4a7aa4..82341a2 100644 --- a/src/components/post-thread-modal/post-thread-node.js +++ b/src/components/post-thread-modal/post-thread-node.js @@ -6,7 +6,7 @@ import React from 'react' import PostFeedItem from '../post-feed/post-feed-item' -function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { +function PostThreadNode ({ post, profiles = {}, wallet, depth = 0, isRoot = false }) { if (!post) return null const profile = profiles[post.addr] || {} @@ -21,6 +21,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { post={post} profile={profile} profiles={profiles} + wallet={wallet} showRepliedLabel={!isRoot} showReplyCount={false} embedded @@ -30,6 +31,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { key={reply.txid} post={reply} profiles={profiles} + wallet={wallet} depth={depth + 1} /> ))} diff --git a/src/services/hex.js b/src/services/hex.js new file mode 100644 index 0000000..5ef989a --- /dev/null +++ b/src/services/hex.js @@ -0,0 +1,28 @@ +/* + Hex encoding helpers used by Memo protocol actions. + + A Bitcoin Cash transaction id is a 32-byte value encoded as a 64-character + hex string. Memo actions like replies and likes need to embed that txid in + the OP_RETURN payload as raw bytes, so this module provides a small, + testable conversion helper. +*/ + +// Decode a hex string into a Uint8Array of the requested byte length. +// The label parameter customizes error messages for the caller's context. +function hexToBytes (hex, byteLength = 32, label = 'Value') { + if (typeof hex !== 'string' || hex.length !== byteLength * 2) { + throw new Error(`${label} must be a ${byteLength * 2}-character hex string.`) + } + + const bytes = new Uint8Array(byteLength) + for (let i = 0; i < hex.length; i += 2) { + const byte = parseInt(hex.substr(i, 2), 16) + if (Number.isNaN(byte)) { + throw new Error(`${label} must be a valid hex string.`) + } + bytes[i / 2] = byte + } + return bytes +} + +module.exports = { hexToBytes } diff --git a/src/services/like-tip-page.js b/src/services/like-tip-page.js new file mode 100644 index 0000000..7a0c48c --- /dev/null +++ b/src/services/like-tip-page.js @@ -0,0 +1,106 @@ +/* + Like / Tip page behavior: open a modal for a post, validate an optional tip, + and submit a Memo like (0x6d04) with or without a tip. + + This is the testable controller behind the React like/tip modal. It wraps + the Memo like behavior (src/services/memo-like.js) and adds page-level + concerns: holding the target post txid, holding the tip input as a string, + surfacing validation/dust/maximum/balance errors, and closing the modal on + success or cancellation. + + The memoLike and modal state concerns are injected so this module stays free + of UI/network concerns; environmentally unsuitable I/O lives behind those + small adapter boundaries. +*/ + +const PageController = require('./page-controller') + +class LikeTipPage extends PageController { + constructor (deps = {}) { + super(deps) + this.memoLike = deps.memoLike || null + this.tipping = false + this.modalOpen = false + this.postTxid = deps.postTxid || null + this.authorAddress = deps.authorAddress || '' + this.successPath = null + this.validationCodes = ['like_validation', 'like_dust', 'like_maximum', 'like_balance', 'like_empty_balance'] + } + + // Open the like/tip modal for a post and check that the wallet has enough + // spendable balance to cover fees. Returns a result object. + open (postTxid, authorAddress) { + this.postTxid = postTxid + this.authorAddress = authorAddress + this.modalOpen = true + this.submitError = null + this.broadcastError = null + + if (!this.memoLike) { + this.submitError = 'like_validation' + this.broadcastError = 'Like requires a memo like handler.' + return { ok: false, error: this.submitError, message: this.broadcastError } + } + + try { + const spendable = this.memoLike.getSpendableSats() + if (spendable < this.memoLike.dustLimit) { + const err = new Error('add BCH to your wallet before liking a post.') + err.code = 'like_empty_balance' + throw err + } + } catch (err) { + return this._handleSubmitFailure(err) + } + + return { ok: true } + } + + // Close the modal and reset input/errors. + close () { + this.modalOpen = false + this.submitError = null + this.broadcastError = null + this.input = '' + } + + // Set the tip amount as a raw string. The string form is validated on submit + // so non-numeric or decimal input can be rejected. + setTip (tipStr) { + this.setInput(tipStr) + } + + // Set the in-flight tipping flag. + _setBusy (value) { + this.tipping = value + } + + // Parse a non-empty tip string into an integer number of satoshis. + _parseTip (input) { + if (input === '' || input === null || input === undefined) return 0 + if (!/^\d+$/.test(String(input))) { + const err = new Error('Tip must be a valid number of satoshis.') + err.code = 'like_validation' + throw err + } + return parseInt(input, 10) + } + + // Run the memo like action for the current post and tip. + async _perform (input) { + if (!this.memoLike) { + throw new Error('Like requires a memo like handler.') + } + const tipSats = this._parseTip(input) + return this.memoLike.like(this.postTxid, tipSats, this.authorAddress) + } + + // Surface the real error message for every failure, including local + // validation failures, so the like/tip modal can display it. + _handleSubmitFailure (err) { + this.broadcastError = err.message || String(err) + return super._handleSubmitFailure(err) + } +} + +module.exports = LikeTipPage diff --git a/src/services/memo-like.js b/src/services/memo-like.js new file mode 100644 index 0000000..cff2127 --- /dev/null +++ b/src/services/memo-like.js @@ -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 diff --git a/src/services/memo-reply.js b/src/services/memo-reply.js index 880e72f..ec9f0ce 100644 --- a/src/services/memo-reply.js +++ b/src/services/memo-reply.js @@ -18,6 +18,7 @@ const MemoAction = require('./memo-action') const { byteLength } = require('./utf8') +const { hexToBytes } = require('./hex') const MEMO_REPLY_PREFIX = '6d03' const MAX_REPLY_BYTES = 184 @@ -82,7 +83,7 @@ class MemoReply extends MemoAction { // Build the raw OP_RETURN message payload for a reply. // The protocol wire format is: . function buildReplyPayload (parentTxid, message) { - const parentBytes = hexToBytes(parentTxid) + const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, 'Parent txid') const textBytes = new TextEncoder().encode(message) const raw = new Uint8Array(parentBytes.length + textBytes.length) raw.set(parentBytes, 0) @@ -90,22 +91,6 @@ function buildReplyPayload (parentTxid, message) { return raw } -// Decode a 64-character hex transaction id into 32 raw bytes. -function hexToBytes (hex) { - if (typeof hex !== 'string' || hex.length !== PARENT_TXID_BYTES * 2) { - throw new Error('Parent txid must be a 64-character hex string.') - } - const bytes = new Uint8Array(PARENT_TXID_BYTES) - for (let i = 0; i < hex.length; i += 2) { - const byte = parseInt(hex.substr(i, 2), 16) - if (Number.isNaN(byte)) { - throw new Error('Parent txid must be a valid hex string.') - } - bytes[i / 2] = byte - } - return bytes -} - MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES diff --git a/src/services/optimistic-reply.js b/src/services/optimistic-reply.js index 3f52a2b..2e96f45 100644 --- a/src/services/optimistic-reply.js +++ b/src/services/optimistic-reply.js @@ -1,5 +1,4 @@ - // Build the optimistic reply object used by the reply form for immediate // thread rendering before the thread is refreshed from the network. This is a // pure data-shaping function so the reply object shape is unit-testable and diff --git a/test/helpers/fake-wallet.js b/test/helpers/fake-wallet.js index 2d2feb0..d5ee57c 100644 --- a/test/helpers/fake-wallet.js +++ b/test/helpers/fake-wallet.js @@ -6,20 +6,28 @@ // sendOpReturn throw. function fakeWallet ({ cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', - utxos = [{ txid: 'utxo1' }], + utxos = [{ txid: 'utxo1', value: 100000 }], txid = 'fake-txid' } = {}) { const broadcasts = [] + const sends = [] const wallet = { walletInfo: { cashAddress }, + utxos, getUtxos: async () => utxos, - sendOpReturn: async function (msg, prefix) { - broadcasts.push({ msg, prefix }) + sendOpReturn: async function (msg, prefix, bchOutput = []) { + broadcasts.push({ msg, prefix, bchOutput }) + if (this.failWith) throw new Error(this.failWith) + return txid + }, + send: async function (receivers) { + sends.push(receivers) if (this.failWith) throw new Error(this.failWith) return txid } } wallet.broadcasts = broadcasts + wallet.sends = sends return wallet } diff --git a/test/property/like-tip.property.test.js b/test/property/like-tip.property.test.js new file mode 100644 index 0000000..c919a23 --- /dev/null +++ b/test/property/like-tip.property.test.js @@ -0,0 +1,116 @@ +/* + Property tests for the Memo like / tip behavior slices. + + The like/tip slice centers on a post txid encoded as a 64-character hex + string. These tests assert useful invariants across a broad input range that + unit tests cover only at a few fixed points: + - hexToBytes round-trips any valid hex txid back to its canonical string. + - hexToBytes rejects any string that is not a valid 64-char hex txid. + - MemoLike.validate accepts any valid 64-char hex txid and rejects others. + - getSpendableSats conserves the sum of every spendable utxo value. +*/ + +'use strict' + +const test = require('node:test') + +const { forAll, seededRandom } = require('./harness') +const { fakeWallet } = require('../helpers/fake-wallet') + +const MemoLike = require('../../src/services/memo-like') +const { hexToBytes } = require('../../src/services/hex') + +const rng = seededRandom(20260720) + +// Build a random lowercase-hex string of the given byte length. +function hexString (bytes) { + const out = [] + for (let i = 0; i < bytes; i++) { + out.push(Math.floor(rng() * 256).toString(16).padStart(2, '0')) + } + return out.join('') +} + +test('hexToBytes round-trips a valid hex txid back to its canonical string', async () => { + await forAll( + () => hexString(32), + (hex) => Buffer.from(hexToBytes(hex, 32)).toString('hex') === hex, + { label: 'hexToBytes round-trip' } + ) +}) + +test('hexToBytes rejects any string that is not a 64-character hex txid', async () => { + await forAll( + (i) => { + const len = 1 + Math.floor(rng() * 100) + if (len !== 64) return 'z'.repeat(len) + // Exactly 64 chars but containing a non-hex character. + return `z${'a'.repeat(63)}` + }, + (input) => { + try { + hexToBytes(input, 32) + return false + } catch (err) { + return err instanceof Error + } + }, + { label: 'hexToBytes rejects invalid' } + ) +}) + +test('MemoLike.validate accepts any valid 64-character hex post txid', async () => { + await forAll( + () => hexString(32), + (txid) => { + const result = new MemoLike({}).validate(txid) + return result.ok === true + }, + { label: 'valid txid accepted' } + ) +}) + +test('MemoLike.validate rejects a non-hex post txid with like_validation', async () => { + await forAll( + () => { + // 64 chars drawn from g..z, guaranteed to be non-hex. + const chars = [] + for (let i = 0; i < 64; i++) { + chars.push(String.fromCharCode(103 + Math.floor(rng() * 20))) + } + return chars.join('') + }, + (txid) => { + try { + new MemoLike({}).validate(txid) + return false + } catch (err) { + return err.code === 'like_validation' + } + }, + { label: 'invalid txid rejected as like_validation' } + ) +}) + +test('getSpendableSats conserves the sum of every spendable utxo value', async () => { + await forAll( + (i) => { + const fields = ['value', 'satoshis', 'amount'] + const count = 1 + Math.floor(rng() * 8) + const utxos = [] + for (let k = 0; k < count; k++) { + utxos.push({ [fields[k % 3]]: Math.floor(rng() * 1000000) }) + } + return utxos + }, + (utxos) => { + const wallet = fakeWallet({ utxos }) + const expected = utxos.reduce( + (sum, u) => sum + (u.value ?? u.satoshis ?? u.amount ?? 0), + 0 + ) + return new MemoLike({ wallet }).getSpendableSats() === expected + }, + { label: 'spendable sum conservation' } + ) +}) diff --git a/test/unit/hex.test.js b/test/unit/hex.test.js new file mode 100644 index 0000000..c6145ad --- /dev/null +++ b/test/unit/hex.test.js @@ -0,0 +1,50 @@ +/* + Unit tests for the hex conversion helper (src/services/hex.js). + + Memo actions like replies and likes embed a post txid in the OP_RETURN + payload as raw bytes, so the helper must decode a canonical 64-character hex + string into exactly 32 bytes and reject anything else with a clear error. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { hexToBytes } = require('../../src/services/hex') + +test('hexToBytes decodes a 64-char hex string into 32 bytes', () => { + const hex = 'a'.repeat(64) + const bytes = hexToBytes(hex, 32) + assert.ok(bytes instanceof Uint8Array) + assert.equal(bytes.length, 32) + assert.equal(Buffer.from(bytes).toString('hex'), hex) +}) + +test('hexToBytes rejects a string of the wrong length', () => { + assert.throws( + () => hexToBytes('a'.repeat(10), 32), + /64-character hex string/ + ) +}) + +test('hexToBytes rejects a 64-char string containing a non-hex character', () => { + assert.throws( + () => hexToBytes(`z${'a'.repeat(63)}`, 32), + /valid hex string/ + ) +}) + +test('hexToBytes rejects non-string input', () => { + assert.throws( + () => hexToBytes(null, 32), + /64-character hex string/ + ) +}) + +test('hexToBytes uses a custom label in error messages', () => { + assert.throws( + () => hexToBytes('nope', 32, 'Post txid'), + /Post txid/ + ) +}) diff --git a/test/unit/like-tip-page.test.js b/test/unit/like-tip-page.test.js new file mode 100644 index 0000000..865ea7c --- /dev/null +++ b/test/unit/like-tip-page.test.js @@ -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') +}) diff --git a/test/unit/memo-like.test.js b/test/unit/memo-like.test.js new file mode 100644 index 0000000..4667318 --- /dev/null +++ b/test/unit/memo-like.test.js @@ -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) +})