Files
psf-memo-client/test/unit/memo-like.test.js
T
Chris Troutner 962b2eb627 Lower tip dust limit to 600 sats and keep the like/tip modal editable after errors
Allow tips as low as 600 sats (tip output dust) while keeping the wallet
balance gate at 3000 sats to cover transaction fees. Keep the tip input and
Like button enabled after a validation error so the user can edit and retry.

By specifier.
2026-08-26 07:10:06 -07:00

294 lines
10 KiB
JavaScript

/*
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)
})