Implement Like / Tip a Memo

Adds the Memo like action (0x6d04) with optional author tip, a like/tip
page controller, heart icon + modal UI, and acceptance handlers for
specs/like-tip-memo.feature.

By coder.
This commit is contained in:
Chris Troutner
2026-08-26 06:14:01 -07:00
parent eb9aa293ee
commit dd0535d133
16 changed files with 1323 additions and 32 deletions
+237 -3
View File
@@ -26,10 +26,16 @@ const ReplyThreadPage = require('../../src/services/reply-thread-page')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const AccountPage = require('../../src/services/account-page')
const MemoLike = require('../../src/services/memo-like')
const LikeTipPage = require('../../src/services/like-tip-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
function makeWallet (address) {
@@ -40,9 +46,9 @@ function makeWallet (address) {
getUtxos: async function () {
return this.utxos
},
sendOpReturn: async function (msg, prefix) {
sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Record the broadcast attempt, then fail if configured to do so.
this.broadcasts.push({ msg, prefix })
this.broadcasts.push({ msg, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32)
}
@@ -86,14 +92,18 @@ function createWorld () {
const memoPost = new MemoPost({ wallet, feed })
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const memoLike = new MemoLike({ wallet, feed })
const world = {
wallet,
feed,
thread,
memoPost,
memoReply,
memoLike,
currentPath: null,
menuOpen: false
menuOpen: false,
likedTxids: new Set()
}
// The New Post Page controller wraps the memo post behavior. Its navigate
@@ -111,6 +121,9 @@ function createWorld () {
navigate: () => {}
})
// The Like / Tip Page controller wraps the memo like behavior.
world.likeTipPage = new LikeTipPage({ memoLike })
// The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other.
const profiles = makeProfiles()
@@ -136,6 +149,24 @@ function decodeReplyPayload (raw) {
return { parentTxid, text }
}
// Decode a raw like payload back into the liked post txid (hex).
function decodeLikeTxid (raw) {
return Buffer.from(raw).toString('hex')
}
// Resolve a literal value or a <parameter> placeholder from the example store.
function resolveParam (value, example) {
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
if (match) {
const param = match[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
return example[param]
}
return String(value).trim()
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
@@ -562,6 +593,209 @@ const handlers = [
throw new Error('Account page does not show a Set Name button.')
}
}
},
{
name: 'wallet has spendable balance',
pattern: /^the wallet has a spendable balance of (.+) sats$/,
run (m, example, world) {
const balance = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(balance)) {
throw new Error(`Invalid balance value "${m[1]}"`)
}
world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }]
}
},
{
name: 'post with txid authored by author address',
pattern: /^a post with the txid (.+) authored by the author address$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = {
txid,
addr: AUTHOR_ADDRESS,
address: AUTHOR_ADDRESS,
text: 'A sample post',
likeCount: 0
}
world.feed.addPost(post)
}
},
{
name: 'post with txid authored by my address',
pattern: /^a post with the txid (.+) authored by my address$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const myAddress = world.wallet.walletInfo.cashAddress
const post = {
txid,
addr: myAddress,
address: myAddress,
text: 'My own post',
likeCount: 0
}
world.feed.addPost(post)
}
},
{
name: 'click heart icon on post',
pattern: /^I click the heart icon on the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = world.feed.posts.find((p) => p.txid === txid)
const authorAddress = post ? post.addr : AUTHOR_ADDRESS
world.likeTipPage.open(txid, authorAddress)
}
},
{
name: 'like/tip modal opens for post',
pattern: /^a like\/tip modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
if (!world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to be open.')
}
if (world.likeTipPage.postTxid !== txid) {
throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`)
}
}
},
{
name: 'submit like without tip',
pattern: /^I submit the like without a tip$/,
async run (m, example, world) {
world.likeTipPage.setTip('')
const result = await world.likeTipPage.submit()
if (result.ok) {
world.likedTxids.add(world.likeTipPage.postTxid)
}
}
},
{
name: 'enter tip',
pattern: /^I enter a tip of (.+)$/,
run (m, example, world) {
world.likeTipPage.setTip(resolveParam(m[1], example))
}
},
{
name: 'submit like',
pattern: /^I submit the like$/,
async run (m, example, world) {
const result = await world.likeTipPage.submit()
if (result.ok) {
world.likedTxids.add(world.likeTipPage.postTxid)
}
}
},
{
name: 'broadcasts OP_RETURN with Memo like prefix',
pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_LIKE_PREFIX) {
throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`)
}
if (decodeLikeTxid(last.msg) !== txid) {
throw new Error(`Broadcast liked txid did not match ${txid}.`)
}
}
},
{
name: 'wallet sends no tip',
pattern: /^the wallet sends no tip$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) {
throw new Error('Expected no tip output, but one was present.')
}
}
},
{
name: 'wallet sends tip to author',
pattern: /^the wallet sends a tip of (.+) to the author address$/,
run (m, example, world) {
const expectedTip = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(expectedTip)) {
throw new Error(`Invalid tip value "${m[1]}"`)
}
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) {
throw new Error('Expected a tip output, but none was present.')
}
const tipOutput = last.bchOutput[0]
if (tipOutput.amountSat !== expectedTip) {
throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`)
}
const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid)
const expectedAddress = post ? post.addr : AUTHOR_ADDRESS
if (tipOutput.address !== expectedAddress) {
throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`)
}
}
},
{
name: 'like count increases by one',
pattern: /^the like count on the post increases by one$/,
run (m, example, world) {
const postTxid = world.likeTipPage.postTxid
const post = world.feed.posts.find((p) => p.txid === postTxid)
if (!post) {
throw new Error(`Post ${postTxid} not found in feed.`)
}
if (post.likeCount !== 1) {
throw new Error(`Expected like count to be 1, got ${post.likeCount}.`)
}
}
},
{
name: 'heart icon shows as filled',
pattern: /^the heart icon on the post shows as filled$/,
run (m, example, world) {
const postTxid = world.likeTipPage.postTxid
if (!world.likedTxids.has(postTxid)) {
throw new Error(`Expected heart icon to be filled for ${postTxid}.`)
}
}
},
{
name: 'like/tip modal shows error containing text',
pattern: /^the like\/tip modal shows an error containing "(.+)"$/,
run (m, example, world) {
const expected = m[1]
const actual = world.likeTipPage.broadcastError || ''
if (!actual.includes(expected)) {
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
}
}
},
{
name: 'click cancel button',
pattern: /^I click the cancel button$/,
run (m, example, world) {
world.likeTipPage.close()
}
},
{
name: 'like/tip modal closes',
pattern: /^the like\/tip modal closes$/,
run (m, example, world) {
if (world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to be closed.')
}
}
}
]
+1
View File
@@ -131,6 +131,7 @@ function RecentPosts (props) {
key={post.txid}
post={post}
profiles={profiles}
wallet={appData?.wallet}
onReplyClick={() => openThread(post.txid)}
showFooterMeta
/>
+37
View File
@@ -0,0 +1,37 @@
/*
Like button for a post (heart icon + count).
The icon is filled when the post has been liked in the current session and
outlined otherwise. The count is displayed next to the icon.
*/
import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons'
import './post-feed.css'
function LikeButton ({ count = 0, liked = false, onClick }) {
const label = count === 1 ? '1 like' : `${count} likes`
const icon = liked ? faHeartSolid : faHeartRegular
const className = [
'post-like-button',
liked ? 'post-like-button-liked' : ''
].filter(Boolean).join(' ')
return (
<button
type='button'
className={className}
aria-label={label}
title={label}
onClick={onClick}
>
<FontAwesomeIcon icon={icon} className='post-like-button-icon' />
<span className='post-like-button-count'>{count}</span>
</button>
)
}
export default LikeButton
+133
View File
@@ -0,0 +1,133 @@
/*
Like / Tip modal for a post.
Lets the user submit a Memo like (0x6d04) with an optional satoshi tip to the
post author. The wallet and target post are injected through props. Errors
from validation, dust/maximum/balance checks, and broadcast failures are
surfaced in the modal body.
*/
import React, { useState, useEffect } from 'react'
import { Modal, Form, Button } from 'react-bootstrap'
import MemoLike from '../../services/memo-like'
import LikeTipPage from '../../services/like-tip-page'
import { getDisplayName } from './post-display'
import './post-feed.css'
function formatError (result) {
if (!result || result.ok) return ''
return result.message || ''
}
function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) {
const [tip, setTip] = useState('')
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const displayName = post ? getDisplayName(post.addr, profiles) : ''
// Reset state whenever the modal is shown and check the wallet balance.
useEffect(() => {
if (!show || !post || !wallet) {
setTip('')
setError('')
setSubmitting(false)
return
}
setTip('')
setError('')
setSubmitting(false)
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
const result = page.open(post.txid, post.addr)
if (!result.ok) {
setError(formatError(result))
}
}, [show, post, wallet])
async function handleSubmit () {
if (!post || !wallet) return
setError('')
setSubmitting(true)
try {
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(post.txid, post.addr)
page.setTip(tip)
const result = await page.submit()
if (result.ok) {
setTip('')
if (typeof onSuccess === 'function') {
onSuccess()
}
} else {
setError(formatError(result))
}
} catch (submitErr) {
setError(submitErr.message)
} finally {
setSubmitting(false)
}
}
function handleCancel () {
setTip('')
setError('')
onHide()
}
return (
<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)}
disabled={submitting || !!error}
/>
</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 || !!error || !post || !wallet}
>
{submitting ? 'Liking...' : 'Like'}
</Button>
</Modal.Footer>
</Modal>
)
}
export default LikeTipModal
+42 -2
View File
@@ -2,12 +2,14 @@
Instagram-style post card for feed and thread views.
*/
import React from 'react'
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import AppUtil from '../../util'
import PostReplyCount from '../post-reply-count'
import PostThreadAvatar from '../post-thread-modal/post-thread-avatar'
import LikeButton from './like-button'
import LikeTipModal from './like-tip-modal'
import {
formatRelativeSeen,
getDisplayName,
@@ -21,14 +23,20 @@ function PostFeedItem ({
post,
profile = {},
profiles = {},
wallet,
onReplyClick,
showRepliedLabel = false,
showFooterMeta = false,
showReplyCount = true,
showLikeButton = true,
embedded = false
}) {
if (!post) return null
const [liked, setLiked] = useState(false)
const [likeCount, setLikeCount] = useState(post.likeCount || 0)
const [showLikeModal, setShowLikeModal] = useState(false)
const displayName = getDisplayName(post.addr, profiles)
const hasCustomName = Boolean(
@@ -46,6 +54,20 @@ function PostFeedItem ({
appUtil.copyToClipboard(post.txid)
}
const handleLikeClick = () => {
setShowLikeModal(true)
}
const handleLikeSuccess = () => {
setLiked(true)
setLikeCount((count) => count + 1)
setShowLikeModal(false)
}
const handleLikeModalHide = () => {
setShowLikeModal(false)
}
return (
<Wrapper
className={[
@@ -117,12 +139,21 @@ function PostFeedItem ({
</p>
</div>
{showReplyCount && (
{(showReplyCount || showLikeButton) && (
<div className='posts-feed-item-actions'>
{showLikeButton && (
<LikeButton
count={likeCount}
liked={liked}
onClick={handleLikeClick}
/>
)}
{showReplyCount && (
<PostReplyCount
count={post.replyCount ?? 0}
onClick={onReplyClick}
/>
)}
</div>
)}
@@ -147,6 +178,15 @@ function PostFeedItem ({
</button>
</footer>
)}
<LikeTipModal
show={showLikeModal}
post={post}
wallet={wallet}
profiles={profiles}
onHide={handleLikeModalHide}
onSuccess={handleLikeSuccess}
/>
</Wrapper>
)
}
+59
View File
@@ -603,3 +603,62 @@
padding: 0 0.75rem;
}
}
.post-like-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
padding: 4px 0;
color: var(--ig-text);
background: transparent;
border: 0;
border-radius: 0;
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-decoration: none;
cursor: pointer;
transition:
opacity 0.15s ease,
color 0.15s ease;
}
.post-like-button:hover {
color: var(--ig-danger);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
opacity: 0.65;
}
.post-like-button-liked,
.post-like-button-liked:hover {
color: var(--ig-danger);
opacity: 1;
}
.post-like-button-icon {
font-size: 18px;
}
.post-like-button-count {
min-width: 1.2em;
}
.like-tip-modal-target {
margin-bottom: 1rem;
}
.like-tip-modal-error {
margin: 0;
font-size: 0.95rem;
}
+2 -1
View File
@@ -107,7 +107,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil
{!loading && !error && thread && (
<>
<PostThreadNode post={thread} profiles={profiles} isRoot />
<PostThreadNode post={thread} profiles={profiles} wallet={wallet} isRoot />
<ReplyThreadForm
parentTxid={txid}
rootPost={thread}
@@ -120,6 +120,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil
key={reply.txid}
post={reply}
profiles={profiles}
wallet={wallet}
/>
))}
</>
@@ -6,7 +6,7 @@ import React from 'react'
import PostFeedItem from '../post-feed/post-feed-item'
function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
function PostThreadNode ({ post, profiles = {}, wallet, depth = 0, isRoot = false }) {
if (!post) return null
const profile = profiles[post.addr] || {}
@@ -21,6 +21,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
post={post}
profile={profile}
profiles={profiles}
wallet={wallet}
showRepliedLabel={!isRoot}
showReplyCount={false}
embedded
@@ -30,6 +31,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
key={reply.txid}
post={reply}
profiles={profiles}
wallet={wallet}
depth={depth + 1}
/>
))}
+28
View File
@@ -0,0 +1,28 @@
/*
Hex encoding helpers used by Memo protocol actions.
A Bitcoin Cash transaction id is a 32-byte value encoded as a 64-character
hex string. Memo actions like replies and likes need to embed that txid in
the OP_RETURN payload as raw bytes, so this module provides a small,
testable conversion helper.
*/
// Decode a hex string into a Uint8Array of the requested byte length.
// The label parameter customizes error messages for the caller's context.
function hexToBytes (hex, byteLength = 32, label = 'Value') {
if (typeof hex !== 'string' || hex.length !== byteLength * 2) {
throw new Error(`${label} must be a ${byteLength * 2}-character hex string.`)
}
const bytes = new Uint8Array(byteLength)
for (let i = 0; i < hex.length; i += 2) {
const byte = parseInt(hex.substr(i, 2), 16)
if (Number.isNaN(byte)) {
throw new Error(`${label} must be a valid hex string.`)
}
bytes[i / 2] = byte
}
return bytes
}
module.exports = { hexToBytes }
+106
View File
@@ -0,0 +1,106 @@
/*
Like / Tip page behavior: open a modal for a post, validate an optional tip,
and submit a Memo like (0x6d04) with or without a tip.
This is the testable controller behind the React like/tip modal. It wraps
the Memo like behavior (src/services/memo-like.js) and adds page-level
concerns: holding the target post txid, holding the tip input as a string,
surfacing validation/dust/maximum/balance errors, and closing the modal on
success or cancellation.
The memoLike and modal state concerns are injected so this module stays free
of UI/network concerns; environmentally unsuitable I/O lives behind those
small adapter boundaries.
*/
const PageController = require('./page-controller')
class LikeTipPage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoLike = deps.memoLike || null
this.tipping = false
this.modalOpen = false
this.postTxid = deps.postTxid || null
this.authorAddress = deps.authorAddress || ''
this.successPath = null
this.validationCodes = ['like_validation', 'like_dust', 'like_maximum', 'like_balance', 'like_empty_balance']
}
// Open the like/tip modal for a post and check that the wallet has enough
// spendable balance to cover fees. Returns a result object.
open (postTxid, authorAddress) {
this.postTxid = postTxid
this.authorAddress = authorAddress
this.modalOpen = true
this.submitError = null
this.broadcastError = null
if (!this.memoLike) {
this.submitError = 'like_validation'
this.broadcastError = 'Like requires a memo like handler.'
return { ok: false, error: this.submitError, message: this.broadcastError }
}
try {
const spendable = this.memoLike.getSpendableSats()
if (spendable < this.memoLike.dustLimit) {
const err = new Error('add BCH to your wallet before liking a post.')
err.code = 'like_empty_balance'
throw err
}
} catch (err) {
return this._handleSubmitFailure(err)
}
return { ok: true }
}
// Close the modal and reset input/errors.
close () {
this.modalOpen = false
this.submitError = null
this.broadcastError = null
this.input = ''
}
// Set the tip amount as a raw string. The string form is validated on submit
// so non-numeric or decimal input can be rejected.
setTip (tipStr) {
this.setInput(tipStr)
}
// Set the in-flight tipping flag.
_setBusy (value) {
this.tipping = value
}
// Parse a non-empty tip string into an integer number of satoshis.
_parseTip (input) {
if (input === '' || input === null || input === undefined) return 0
if (!/^\d+$/.test(String(input))) {
const err = new Error('Tip must be a valid number of satoshis.')
err.code = 'like_validation'
throw err
}
return parseInt(input, 10)
}
// Run the memo like action for the current post and tip.
async _perform (input) {
if (!this.memoLike) {
throw new Error('Like requires a memo like handler.')
}
const tipSats = this._parseTip(input)
return this.memoLike.like(this.postTxid, tipSats, this.authorAddress)
}
// Surface the real error message for every failure, including local
// validation failures, so the like/tip modal can display it.
_handleSubmitFailure (err) {
this.broadcastError = err.message || String(err)
return super._handleSubmitFailure(err)
}
}
module.exports = LikeTipPage
+167
View File
@@ -0,0 +1,167 @@
/*
Memo like/tip behavior: compose, validate, and broadcast a Memo "like"
action with an optional tip.
A Memo like is an OP_RETURN Bitcoin Cash transaction carrying the Memo like
protocol prefix (0x6d04) followed by the liked post transaction hash (32
bytes). When the user adds a tip, an additional P2PKH output sending the tip
amount to the post author's address is included in the same transaction.
Broadcasting is done through a wallet that exposes the minimal-slp-wallet
adapter surface (walletInfo, getUtxos(), sendOpReturn(msg, prefix, bchOutput)).
The wallet and feed are injected so this module stays testable and free of
network/UI concerns; environmentally unsuitable I/O lives behind those small
adapter boundaries.
Constants
MEMO_LIKE_PREFIX : hex prefix for the Memo "like" action (0x6d04)
DUST_LIMIT_SATS : smallest non-dust BCH output (3000 sats)
MAX_TIP_SATS : sanity maximum for a single tip (100000000 sats = 1 BCH)
PARENT_TXID_BYTES : liked post txid size in bytes (32)
*/
const MemoAction = require('./memo-action')
const { hexToBytes } = require('./hex')
const MEMO_LIKE_PREFIX = '6d04'
const DUST_LIMIT_SATS = 3000
const MAX_TIP_SATS = 100000000
const PARENT_TXID_BYTES = 32
class MemoLike extends MemoAction {
static config = {
prefix: MEMO_LIKE_PREFIX,
walletRequiredMsg: 'Memo like requires a wallet.',
lengthMessage: 'Post txid must be a 64-character hex string.',
emptyMessage: 'Post txid must be a 64-character hex string.',
lengthCode: 'like_validation',
validationCode: 'like_validation'
}
constructor (deps = {}) {
super(deps)
this.feed = deps.feed
this.dustLimit = deps.dustLimit || DUST_LIMIT_SATS
this.maxTip = deps.maxTip || MAX_TIP_SATS
}
// Validate a candidate post txid.
// Returns { ok: true } or throws a typed validation error.
validate (postTxid) {
try {
hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
return { ok: true }
} catch (err) {
const validationErr = new Error(err.message)
validationErr.code = this.validationCode
throw validationErr
}
}
// Validate an optional tip amount against the dust limit, hard maximum, and
// the wallet's spendable balance.
validateTip (tipSats, spendableSats) {
if (!Number.isInteger(tipSats) || tipSats < 0) {
const err = new Error('Tip must be a valid number of satoshis.')
err.code = 'like_validation'
throw err
}
if (tipSats > 0 && tipSats < this.dustLimit) {
const err = new Error(`Tip is below the dust limit of ${this.dustLimit} sats.`)
err.code = 'like_dust'
throw err
}
if (tipSats > this.maxTip) {
const err = new Error(`Tip exceeds the maximum of ${this.maxTip} sats.`)
err.code = 'like_maximum'
throw err
}
if (tipSats > spendableSats) {
const err = new Error('Tip exceeds the spendable balance.')
err.code = 'like_balance'
throw err
}
return { ok: true }
}
// Sum the wallet's spendable UTXOs. Tolerates the common value field names
// used by different wallet adapters.
getSpendableSats () {
if (!this.wallet) return 0
const utxos = this.wallet.utxos || []
return utxos.reduce((sum, u) => {
const value = u.value ?? u.satoshis ?? u.amount ?? 0
return sum + value
}, 0)
}
// Compose and broadcast a Memo like for the given post txid.
// tipSats is optional (defaults to 0). authorAddress is required when
// tipSats is greater than 0.
// Resolves with the transaction id, or rejects with a typed error.
async like (postTxid, tipSats = 0, authorAddress = '') {
this.validate(postTxid)
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
await this.wallet.getUtxos()
const spendable = this.getSpendableSats()
if (spendable < this.dustLimit) {
const err = new Error('add BCH to your wallet before liking a post.')
err.code = 'like_empty_balance'
throw err
}
this.validateTip(tipSats, spendable)
if (tipSats > 0 && (!authorAddress || typeof authorAddress !== 'string')) {
const err = new Error('Tip requires an author address.')
err.code = 'like_validation'
throw err
}
const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
const bchOutput = tipSats > 0
? [{ address: authorAddress, amountSat: tipSats }]
: []
const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput)
this.reflect(txid, postTxid, tipSats)
return txid
}
// Record the new like on the injected feed when one is present.
reflect (txid, postTxid, tipSats) {
if (this.feed && typeof this.feed.addLike === 'function') {
this.feed.addLike({
txid,
postTxid,
address: this.wallet.walletInfo.cashAddress,
tipSats
})
}
if (this.feed && Array.isArray(this.feed.posts)) {
const post = this.feed.posts.find((p) => p.txid === postTxid)
if (post) {
post.likeCount = (post.likeCount || 0) + 1
}
}
}
}
MemoLike.MEMO_LIKE_PREFIX = MEMO_LIKE_PREFIX
MemoLike.DUST_LIMIT_SATS = DUST_LIMIT_SATS
MemoLike.MAX_TIP_SATS = MAX_TIP_SATS
module.exports = MemoLike
+2 -17
View File
@@ -18,6 +18,7 @@
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const { hexToBytes } = require('./hex')
const MEMO_REPLY_PREFIX = '6d03'
const MAX_REPLY_BYTES = 184
@@ -82,7 +83,7 @@ class MemoReply extends MemoAction {
// Build the raw OP_RETURN message payload for a reply.
// The protocol wire format is: <parent txid 32 bytes><reply text UTF-8 bytes>.
function buildReplyPayload (parentTxid, message) {
const parentBytes = hexToBytes(parentTxid)
const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, 'Parent txid')
const textBytes = new TextEncoder().encode(message)
const raw = new Uint8Array(parentBytes.length + textBytes.length)
raw.set(parentBytes, 0)
@@ -90,22 +91,6 @@ function buildReplyPayload (parentTxid, message) {
return raw
}
// Decode a 64-character hex transaction id into 32 raw bytes.
function hexToBytes (hex) {
if (typeof hex !== 'string' || hex.length !== PARENT_TXID_BYTES * 2) {
throw new Error('Parent txid must be a 64-character hex string.')
}
const bytes = new Uint8Array(PARENT_TXID_BYTES)
for (let i = 0; i < hex.length; i += 2) {
const byte = parseInt(hex.substr(i, 2), 16)
if (Number.isNaN(byte)) {
throw new Error('Parent txid must be a valid hex string.')
}
bytes[i / 2] = byte
}
return bytes
}
MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX
MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
-1
View File
@@ -1,5 +1,4 @@
// Build the optimistic reply object used by the reply form for immediate
// thread rendering before the thread is refreshed from the network. This is a
// pure data-shaping function so the reply object shape is unit-testable and
+11 -3
View File
@@ -6,20 +6,28 @@
// sendOpReturn throw.
function fakeWallet ({
cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
utxos = [{ txid: 'utxo1' }],
utxos = [{ txid: 'utxo1', value: 100000 }],
txid = 'fake-txid'
} = {}) {
const broadcasts = []
const sends = []
const wallet = {
walletInfo: { cashAddress },
utxos,
getUtxos: async () => utxos,
sendOpReturn: async function (msg, prefix) {
broadcasts.push({ msg, prefix })
sendOpReturn: async function (msg, prefix, bchOutput = []) {
broadcasts.push({ msg, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return txid
},
send: async function (receivers) {
sends.push(receivers)
if (this.failWith) throw new Error(this.failWith)
return txid
}
}
wallet.broadcasts = broadcasts
wallet.sends = sends
return wallet
}
+230
View File
@@ -0,0 +1,230 @@
/*
Unit tests for the Like / Tip page behavior slice
(src/services/like-tip-page.js).
These tests express the page-level behavior described by
specs/like-tip-memo.feature:
- opening the modal for a post checks the wallet balance.
- submitting a like without a tip broadcasts the Memo like action.
- submitting a like with a tip broadcasts the action and the tip.
- invalid, dust, maximum, and balance errors are surfaced.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const LikeTipPage = require('../../src/services/like-tip-page')
const MemoLike = require('../../src/services/memo-like')
const { fakeWallet } = require('../helpers/fake-wallet')
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function build () {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
const feed = { posts: [], likes: [], addLike: (l) => feed.likes.push(l) }
const memoLike = new MemoLike({ wallet, feed })
const page = new LikeTipPage({ memoLike })
return { wallet, feed, memoLike, page }
}
test('opening the modal sets the target post and author', () => {
const { page } = build()
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, true)
assert.equal(page.modalOpen, true)
assert.equal(page.postTxid, POST_TXID)
assert.equal(page.authorAddress, AUTHOR_ADDRESS)
})
test('opening the modal with insufficient balance surfaces an add-BCH error', () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [] })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, false)
assert.equal(result.error, 'like_empty_balance')
assert.equal(page.submitError, 'like_empty_balance')
assert.match(page.broadcastError, /add BCH/i)
assert.equal(page.modalOpen, true)
})
test('opening the modal without a memo-like handler surfaces an error', () => {
const page = new LikeTipPage({})
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.match(page.broadcastError, /memo like/i)
})
test('closing the modal resets input and errors', () => {
const { page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('5000')
page.submitError = 'like_dust'
page.broadcastError = 'some error'
page.close()
assert.equal(page.modalOpen, false)
assert.equal(page.input, '')
assert.equal(page.submitError, null)
assert.equal(page.broadcastError, null)
})
test('submitting a pure like broadcasts the Memo like prefix', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page.tipping, false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.deepEqual(wallet.broadcasts[0].bchOutput, [])
})
test('submitting a like with a tip broadcasts the prefix and the tip output', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('3000')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
})
test('submitting a like on a post authored by the wallet works without a tip', async () => {
const { wallet, page } = build()
page.open(POST_TXID, MY_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
})
test('submitting with a non-numeric tip is rejected', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('abc')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.equal(page.submitError, 'like_validation')
assert.match(page.broadcastError, /valid number/i)
assert.equal(wallet.broadcasts.length, 0)
})
test('submitting with a decimal tip string is rejected', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('1.5')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.equal(wallet.broadcasts.length, 0)
})
test('submitting with a dust tip is rejected', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('2999')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_dust')
assert.equal(wallet.broadcasts.length, 0)
})
test('submitting with a tip above the maximum is rejected', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 150000000 }] })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('100000001')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_maximum')
assert.match(page.broadcastError, /maximum/i)
assert.equal(wallet.broadcasts.length, 0)
})
test('submitting with a tip above the spendable balance is rejected', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 30000 }] })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('35000')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_balance')
assert.match(page.broadcastError, /spendable/i)
assert.equal(wallet.broadcasts.length, 0)
})
test('tipping flag is true while a submit is in flight and false once it settles', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
assert.equal(page.tipping, false)
const pending = page.submit()
assert.equal(page.tipping, true)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page.tipping, false)
})
test('submitting without a memo-like handler reports an error', async () => {
const page = new LikeTipPage({})
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /memo like/i)
})
test('a failed broadcast surfaces the real error', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
wallet.failWith = 'Insufficient balance'
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /Insufficient balance/)
})
+261
View File
@@ -0,0 +1,261 @@
/*
Unit tests for the Memo like/tip behavior slice (src/services/memo-like.js).
These tests express the observable behavior described by
specs/like-tip-memo.feature:
- a valid like broadcasts an OP_RETURN transaction carrying the Memo like
prefix (0x6d04) and the post txid bytes; an optional tip is included as a
BCH output to the author address.
- an invalid/non-integer tip is rejected with a validation error.
- a tip below the dust limit is rejected.
- a tip above the hard maximum is rejected.
- a tip above the spendable balance is rejected.
- a wallet without spendable balance cannot like.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoLike = require('../../src/services/memo-like')
const { fakeWallet } = require('../helpers/fake-wallet')
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
// A fake feed that records likes and exposes posts keyed by txid.
function fakeFeed (posts = []) {
const likes = []
return {
posts,
likes,
addLike: (l) => likes.push(l)
}
}
// Decode a like payload back into a 64-character hex txid.
function decodeLikeTxid (raw) {
return Buffer.from(raw).toString('hex')
}
test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => {
assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04')
})
test('DUST_LIMIT_SATS is 3000', () => {
assert.equal(MemoLike.DUST_LIMIT_SATS, 3000)
})
test('MAX_TIP_SATS is 100000000', () => {
assert.equal(MemoLike.MAX_TIP_SATS, 100000000)
})
test('liking a post without a tip broadcasts an OP_RETURN with the Memo like prefix and txid', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 0 }])
const memoLike = new MemoLike({ wallet, feed })
const txid = await memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d04')
assert.ok(b.msg instanceof Uint8Array)
assert.equal(decodeLikeTxid(b.msg), POST_TXID)
assert.deepEqual(b.bchOutput, [])
// The feed reflects the like.
assert.equal(feed.likes.length, 1)
assert.equal(feed.likes[0].postTxid, POST_TXID)
assert.equal(feed.likes[0].address, MY_ADDRESS)
assert.equal(feed.likes[0].tipSats, 0)
assert.equal(feed.posts[0].likeCount, 1)
})
test('liking a post with a tip broadcasts an OP_RETURN and a tip output', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 5 }])
const memoLike = new MemoLike({ wallet, feed })
const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d04')
assert.equal(decodeLikeTxid(b.msg), POST_TXID)
assert.deepEqual(b.bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
// The feed reflects the like.
assert.equal(feed.likes.length, 1)
assert.equal(feed.likes[0].tipSats, 3000)
assert.equal(feed.posts[0].likeCount, 6)
})
test('liking without a wallet reports a missing-wallet error', async () => {
const memoLike = new MemoLike({})
await assert.rejects(
memoLike.like(POST_TXID),
(err) => /wallet/i.test(err.message)
)
})
test('liking with an invalid post txid reports a clear validation error', async () => {
const wallet = fakeWallet()
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like('not-a-txid'),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('liking with a wrong-length but valid-hex post txid is rejected', async () => {
const wallet = fakeWallet()
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like('a'.repeat(10)),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a non-integer tip like "1.5" is rejected with a validation error', async () => {
const wallet = fakeWallet()
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 1.5, AUTHOR_ADDRESS),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a non-numeric tip like "abc" is rejected with a validation error', async () => {
const wallet = fakeWallet()
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, NaN, AUTHOR_ADDRESS),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a negative tip is rejected with a validation error', async () => {
const wallet = fakeWallet()
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, -1, AUTHOR_ADDRESS),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a tip below the dust limit is rejected with a dust error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
const memoLike = new MemoLike({ wallet })
for (const tip of [1, 2999]) {
await assert.rejects(
memoLike.like(POST_TXID, tip, AUTHOR_ADDRESS),
(err) => err.code === 'like_dust'
)
}
assert.equal(wallet.broadcasts.length, 0)
})
test('a tip at the dust limit is accepted', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
const memoLike = new MemoLike({ wallet })
const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
})
test('a tip above the hard maximum is rejected with a maximum error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] })
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 100000001, AUTHOR_ADDRESS),
(err) => err.code === 'like_maximum'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a tip above the spendable balance is rejected with a balance error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] })
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 35000, AUTHOR_ADDRESS),
(err) => err.code === 'like_balance'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a wallet with zero spendable balance cannot like', async () => {
const wallet = fakeWallet({ utxos: [] })
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS),
(err) => err.code === 'like_empty_balance'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a wallet with balance below the dust limit cannot like', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] })
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS),
(err) => err.code === 'like_empty_balance'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('a pure like can be made on a post authored by the wallet address', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
const feed = fakeFeed([{ txid: POST_TXID, addr: MY_ADDRESS, likeCount: 0 }])
const memoLike = new MemoLike({ wallet, feed })
const txid = await memoLike.like(POST_TXID, 0, MY_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.equal(feed.posts[0].likeCount, 1)
})
test('a tip requires an author address', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 3000),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('getSpendableSats tolerates common utxo value field names', () => {
const walletValue = fakeWallet({ utxos: [{ txid: 'u1', value: 1000 }] })
const walletSatoshis = fakeWallet({ utxos: [{ txid: 'u2', satoshis: 2000 }] })
const walletAmount = fakeWallet({ utxos: [{ txid: 'u3', amount: 3000 }] })
assert.equal(new MemoLike({ wallet: walletValue }).getSpendableSats(), 1000)
assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000)
assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000)
})