mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Refactor txid wire encoding and repair library
Share the txid-and-text payload builder between hex and reply, extract the repair store loop from repairTxidEncoding to cut CRAP from 13 to 5, correct the poll-services property test to the little-endian wire order, and add property tests for wire round trips and repair idempotence/conservation. By refactorer.
This commit is contained in:
@@ -35,9 +35,9 @@ function txidToWireBytes (txid, label = 'Value') {
|
||||
|
||||
// Build the raw OP_RETURN payload for a txid-referencing Memo action: the
|
||||
// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded
|
||||
// value.
|
||||
function buildTxidTextPayload (txid, text) {
|
||||
const txidBytes = txidToWireBytes(txid, 'Poll txid')
|
||||
// value. The label customizes the invalid-txid error message.
|
||||
function buildTxidTextPayload (txid, text, label = 'Poll txid') {
|
||||
const txidBytes = txidToWireBytes(txid, label)
|
||||
const textBytes = new TextEncoder().encode(text)
|
||||
const raw = new Uint8Array(txidBytes.length + textBytes.length)
|
||||
raw.set(txidBytes, 0)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { byteLength } = require('./utf8')
|
||||
const { txidToWireBytes } = require('./hex')
|
||||
const { buildTxidTextPayload } = require('./hex')
|
||||
|
||||
const MEMO_REPLY_PREFIX = '6d03'
|
||||
const MAX_REPLY_BYTES = 184
|
||||
@@ -57,7 +57,7 @@ class MemoReply extends MemoAction {
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes.
|
||||
const raw = buildReplyPayload(parentTxid, message)
|
||||
const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid')
|
||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
|
||||
|
||||
// Reflect the result on the injected thread once broadcast succeeds.
|
||||
@@ -79,17 +79,6 @@ 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 = txidToWireBytes(parentTxid, 'Parent txid')
|
||||
const textBytes = new TextEncoder().encode(message)
|
||||
const raw = new Uint8Array(parentBytes.length + textBytes.length)
|
||||
raw.set(parentBytes, 0)
|
||||
raw.set(textBytes, parentBytes.length)
|
||||
return raw
|
||||
}
|
||||
|
||||
MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX
|
||||
MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
|
||||
|
||||
|
||||
@@ -67,12 +67,11 @@ test('buildTxidTextPayload round-trips the canonical Memo wire format', async ()
|
||||
({ txid, text }) => {
|
||||
const bytes = buildTxidTextPayload(txid, text)
|
||||
if (bytes.length !== 32 + byteLength(text)) return false
|
||||
// The payload carries the txid's literal 32 bytes in order, followed by
|
||||
// the UTF-8 bytes of the value.
|
||||
const expectedTxidBytes = hexToBytes(txid, 32, 'Poll txid')
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (bytes[i] !== expectedTxidBytes[i]) return false
|
||||
}
|
||||
// The payload carries the txid in little-endian wire order, followed by
|
||||
// the UTF-8 bytes of the value. Reversing the wire bytes must recover
|
||||
// the 64-character display txid.
|
||||
const wire = Buffer.from(bytes.slice(0, 32))
|
||||
if (wire.reverse().toString('hex') !== txid) return false
|
||||
const storedText = Buffer.from(bytes.slice(32)).toString('utf8')
|
||||
return storedText === text
|
||||
},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Property tests for Memo txid wire encoding.
|
||||
|
||||
Unit tests pin the wire bytes of a few fixed txids. These properties pin the
|
||||
encoding invariants over broad random inputs:
|
||||
|
||||
- Round trip: reversing the wire bytes recovers the display bytes, and
|
||||
reversing twice is the identity.
|
||||
- Wire order: the encoded bytes are exactly the reverse of the display
|
||||
txid's bytes.
|
||||
- Payload shape: a txid-and-text payload starts with the wire txid and
|
||||
ends with the UTF-8 text, with no bytes added or dropped.
|
||||
- Determinism: the same txid always encodes to the same bytes.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const { txidToWireBytes, buildTxidTextPayload } = require('../../src/services/hex')
|
||||
|
||||
const HEX_CHARS = '0123456789abcdef'
|
||||
|
||||
function randomTxid (rng) {
|
||||
let out = ''
|
||||
for (let i = 0; i < 64; i++) {
|
||||
out += HEX_CHARS[Math.floor(rng() * HEX_CHARS.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomText (rng) {
|
||||
const alphabet = 'abc XYZ!\u00e9\u4e2d\ud83d\ude00'
|
||||
let out = ''
|
||||
const length = intGen(rng, 0, 24)()
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += alphabet[Math.floor(rng() * alphabet.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
test('txid wire bytes are the byte reverse of the display txid', async () => {
|
||||
const rng = seededRandom(20260916)
|
||||
|
||||
await forAll(
|
||||
() => randomTxid(rng),
|
||||
(txid) => {
|
||||
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
const expected = Buffer.from(txid, 'hex').reverse().toString('hex')
|
||||
return wire === expected
|
||||
},
|
||||
{ label: 'txidToWireBytes byte order' }
|
||||
)
|
||||
})
|
||||
|
||||
test('reversing wire bytes twice recovers the display txid', async () => {
|
||||
const rng = seededRandom(20260917)
|
||||
|
||||
await forAll(
|
||||
() => randomTxid(rng),
|
||||
(txid) => {
|
||||
const bytes = txidToWireBytes(txid)
|
||||
return Buffer.from(Buffer.from(bytes).reverse()).toString('hex') === txid
|
||||
},
|
||||
{ label: 'txidToWireBytes round trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('txid encoding is deterministic', async () => {
|
||||
const rng = seededRandom(20260918)
|
||||
|
||||
await forAll(
|
||||
() => randomTxid(rng),
|
||||
(txid) => {
|
||||
const first = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
const second = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
return first === second
|
||||
},
|
||||
{ label: 'txidToWireBytes determinism' }
|
||||
)
|
||||
})
|
||||
|
||||
test('payload embeds the wire txid followed by the UTF-8 text', async () => {
|
||||
const rng = seededRandom(20260919)
|
||||
|
||||
await forAll(
|
||||
() => ({ txid: randomTxid(rng), text: randomText(rng) }),
|
||||
({ txid, text }) => {
|
||||
const raw = buildTxidTextPayload(txid, text)
|
||||
const buf = Buffer.from(raw)
|
||||
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
const expectedText = Buffer.from(text, 'utf8')
|
||||
|
||||
if (buf.length !== 32 + expectedText.length) return false
|
||||
if (buf.subarray(0, 32).toString('hex') !== wire) return false
|
||||
return buf.subarray(32).equals(expectedText)
|
||||
},
|
||||
{ label: 'buildTxidTextPayload shape' }
|
||||
)
|
||||
})
|
||||
|
||||
test('a reply payload rejects an invalid txid with the parent label', () => {
|
||||
assert.throws(
|
||||
() => buildTxidTextPayload('not-a-txid', 'hi', 'Parent txid'),
|
||||
/Parent txid must be a 64-character hex string/
|
||||
)
|
||||
})
|
||||
@@ -60,3 +60,118 @@ test('like reflects the post txid in display order on the feed store', async ()
|
||||
assert.equal(added.length, 1)
|
||||
assert.equal(added[0].postTxid, POST_TXID)
|
||||
})
|
||||
|
||||
test('like rejects a caller without a wallet', async () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
await assert.rejects(() => memoLike.like(POST_TXID), /requires a wallet/)
|
||||
})
|
||||
|
||||
test('like rejects an invalid post txid before broadcasting', async () => {
|
||||
const wallet = makeWallet()
|
||||
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('like rejects a balance below the dust limit', async () => {
|
||||
const wallet = makeWallet()
|
||||
wallet.utxos = [{ txid: 'utxo', value: 100 }]
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like(POST_TXID),
|
||||
(err) => err.code === 'like_empty_balance'
|
||||
)
|
||||
})
|
||||
|
||||
test('like rejects a tip that exceeds the spendable balance', async () => {
|
||||
const wallet = makeWallet()
|
||||
wallet.utxos = [{ txid: 'utxo', value: 3000 }]
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like(POST_TXID, 4000, MY_ADDRESS),
|
||||
(err) => err.code === 'like_balance'
|
||||
)
|
||||
})
|
||||
|
||||
test('like requires an author address when a tip is present', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like(POST_TXID, 1000),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
})
|
||||
|
||||
test('_validateTipAmount rejects a non-integer or negative tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(() => memoLike._validateTipAmount(1.5), (err) => err.code === 'like_validation')
|
||||
assert.throws(() => memoLike._validateTipAmount(-1), (err) => err.code === 'like_validation')
|
||||
})
|
||||
|
||||
test('_validateTipAmount rejects a tip below the dust limit', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(() => memoLike._validateTipAmount(1), (err) => err.code === 'like_dust')
|
||||
})
|
||||
|
||||
test('_validateTipAmount rejects a tip above the maximum', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(
|
||||
() => memoLike._validateTipAmount(MemoLike.MAX_TIP_SATS + 1),
|
||||
(err) => err.code === 'like_maximum'
|
||||
)
|
||||
})
|
||||
|
||||
test('_validateTipAmount accepts zero and a valid tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.equal(memoLike._validateTipAmount(0), undefined)
|
||||
assert.equal(memoLike._validateTipAmount(1000), undefined)
|
||||
})
|
||||
|
||||
test('getSpendableSats sums the utxoStore bchUtxos shape', () => {
|
||||
const wallet = {
|
||||
utxos: { utxoStore: { bchUtxos: [{ satoshis: 1000 }, { amount: 500 }] } }
|
||||
}
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
assert.equal(memoLike.getSpendableSats(), 1500)
|
||||
})
|
||||
|
||||
test('getSpendableSats is zero without a wallet', () => {
|
||||
assert.equal(new MemoLike().getSpendableSats(), 0)
|
||||
})
|
||||
|
||||
test('like includes a tip output and reflects the tip on the feed', async () => {
|
||||
const wallet = makeWallet()
|
||||
const added = []
|
||||
const feed = {
|
||||
addLike (like) {
|
||||
added.push(like)
|
||||
},
|
||||
posts: [{ txid: POST_TXID, likeCount: 2 }]
|
||||
}
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
await memoLike.like(POST_TXID, 1000, MY_ADDRESS)
|
||||
|
||||
assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: MY_ADDRESS, amountSat: 1000 }])
|
||||
assert.equal(added[0].tipSats, 1000)
|
||||
assert.equal(feed.posts[0].likeCount, 3)
|
||||
})
|
||||
|
||||
test('_buildTipOutput omits the output for a zero tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.deepEqual(memoLike._buildTipOutput(0, MY_ADDRESS), [])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user