diff --git a/src/services/memo-like.js b/src/services/memo-like.js index eb8ff60..63b0d9b 100644 --- a/src/services/memo-like.js +++ b/src/services/memo-like.js @@ -62,6 +62,19 @@ class MemoLike extends MemoAction { // 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' @@ -79,14 +92,6 @@ class MemoLike extends MemoAction { 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 @@ -121,17 +126,10 @@ class MemoLike extends MemoAction { } 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 - } + this._requireTipAddress(tipSats, authorAddress) const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid') - const bchOutput = tipSats > 0 - ? [{ address: authorAddress, amountSat: tipSats }] - : [] + const bchOutput = this._buildTipOutput(tipSats, authorAddress) const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput) @@ -142,6 +140,28 @@ class MemoLike extends MemoAction { // 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, @@ -150,12 +170,14 @@ class MemoLike extends MemoAction { 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 - } + // 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 } } } 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 index 45baff7..39a033a 100644 --- a/test/unit/like-tip-page.test.js +++ b/test/unit/like-tip-page.test.js @@ -31,6 +31,23 @@ function build () { 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() @@ -118,30 +135,20 @@ test('submitting a like on a post authored by the wallet works without a tip', a 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') +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() + 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) + 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 () => { @@ -157,33 +164,21 @@ test('submitting with a dust tip is rejected', async () => { }) 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) + await assertTipRejected( + [{ txid: 'u1', value: 150000000 }], + '100000001', + 'like_maximum', + /maximum/i + ) }) 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) + 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 () => { @@ -228,3 +223,17 @@ test('a failed broadcast surfaces the real error', async () => { 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 index 835e90c..6ea0341 100644 --- a/test/unit/memo-like.test.js +++ b/test/unit/memo-like.test.js @@ -35,11 +35,22 @@ function fakeFeed (posts = []) { } } -// Decode a like payload back into a 64-character hex txid. +// 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') }) @@ -105,71 +116,29 @@ test('liking without a wallet reports a missing-wallet error', async () => { }) 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) + await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'not-a-txid') }) 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) + await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'a'.repeat(10)) }) 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) + await assertLikeRejected(fakeWallet(), 1.5, 'like_validation') }) 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) + await assertLikeRejected(fakeWallet(), NaN, 'like_validation') }) 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) + await assertLikeRejected(fakeWallet(), -1, 'like_validation') }) 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) + await assertLikeRejected(wallet, 1, 'like_dust') + await assertLikeRejected(wallet, 2999, 'like_dust') }) test('a tip at the dust limit is accepted', async () => { @@ -183,47 +152,31 @@ test('a tip at the dust limit is accepted', async () => { }) 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' + await assertLikeRejected( + fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }), + 100000001, + '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' + await assertLikeRejected( + fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }), + 35000, + '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) + await assertLikeRejected(fakeWallet({ utxos: [] }), 0, 'like_empty_balance') }) 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' + await assertLikeRejected( + fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }), + 0, + '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 () => { @@ -259,3 +212,9 @@ test('getSpendableSats tolerates common utxo value field names', () => { 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) +})