mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Reply, topic message, add-poll-option, poll-vote, and create-poll now broadcast each protocol field as its own OP_RETURN script push instead of combining them into one push. The indexer requires three pushes for reply, topic message, and poll children, and accepts four for create-poll; the combined form was rejected or misparsed. hex.js builds the txid and text as separate pushes, and a new memo-multipush adapter teaches minimal-slp-wallet's single-push sendOpReturn to expand an array of fields into separate pushes. Acceptance step handlers assert the individual pushes, and unit/property tests cover the new shape. By coder.
163 lines
5.4 KiB
JavaScript
163 lines
5.4 KiB
JavaScript
/*
|
|
Property tests for the Memo poll services and their page controllers.
|
|
|
|
These pin down invariants over broad random inputs that the unit tests only
|
|
probe at fixed fixtures:
|
|
|
|
- Round-trip: buildTxidTextPushes encodes a poll txid + text into the
|
|
canonical Memo wire pushes, so the stored reverse-hex txid and the
|
|
UTF-8 text both decode back unchanged.
|
|
- hexToBytes length contract: only 64-character hex txids decode to
|
|
32 bytes; everything else throws.
|
|
- Page byte conservation: remainingCount is always maxBytes minus the
|
|
UTF-8 byte length of the current input.
|
|
- Rejection classification: empty inputs fail as validation and over-long
|
|
inputs fail as length, in both cases without broadcasting.
|
|
*/
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const { seededRandom, forAll } = require('./harness')
|
|
const { hexToBytes, buildTxidTextPushes } = require('../../src/services/hex')
|
|
const { byteLength } = require('../../src/services/utf8')
|
|
const MemoPollOption = require('../../src/services/memo-poll-option')
|
|
const MemoPollVote = require('../../src/services/memo-poll-vote')
|
|
const PollOptionPage = require('../../src/services/poll-option-page')
|
|
const PollVotePage = require('../../src/services/poll-vote-page')
|
|
|
|
const rng = seededRandom(20260828)
|
|
|
|
function randomTxid () {
|
|
const hex = '0123456789abcdef'
|
|
let s = ''
|
|
for (let i = 0; i < 64; i++) {
|
|
s += hex[Math.floor(rng() * 16)]
|
|
}
|
|
return s
|
|
}
|
|
|
|
function randomText (maxBytes) {
|
|
const charset = ['a', 'b', 'c', 'd', ' ', 'é', '\u{1F600}']
|
|
let s = ''
|
|
let budget = maxBytes
|
|
while (budget > 0) {
|
|
const ch = charset[Math.floor(rng() * charset.length)]
|
|
s += ch
|
|
budget -= byteLength(ch)
|
|
}
|
|
return s
|
|
}
|
|
|
|
function makeWallet () {
|
|
return {
|
|
walletInfo: { cashAddress: 'bitcoincash:qtest' },
|
|
broadcasts: [],
|
|
async getUtxos () { return [] },
|
|
async sendOpReturn (msg, prefix) {
|
|
this.broadcasts.push({ msg, prefix })
|
|
return 'aa'.repeat(32)
|
|
}
|
|
}
|
|
}
|
|
|
|
test('buildTxidTextPushes round-trips the canonical Memo wire format', async () => {
|
|
await forAll(
|
|
() => ({ txid: randomTxid(), text: randomText(200) }),
|
|
({ txid, text }) => {
|
|
const pushes = buildTxidTextPushes(txid, text)
|
|
const bytes = Buffer.concat(pushes.map((p) => Buffer.from(p)))
|
|
if (bytes.length !== 32 + byteLength(text)) return false
|
|
// The pushes carry 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
|
|
},
|
|
{ label: 'poll txid+text wire round-trip' }
|
|
)
|
|
})
|
|
|
|
test('hexToBytes decodes only exactly 64 hex characters to 32 bytes', async () => {
|
|
await forAll(
|
|
() => {
|
|
const len = Math.floor(rng() * 80)
|
|
const hex = '0123456789abcdef'
|
|
let s = ''
|
|
for (let i = 0; i < len; i++) {
|
|
s += hex[Math.floor(rng() * 16)]
|
|
}
|
|
return s
|
|
},
|
|
(s) => {
|
|
if (s.length === 64) {
|
|
return hexToBytes(s, 32, 'Poll txid').length === 32
|
|
}
|
|
let threw = false
|
|
try {
|
|
hexToBytes(s, 32, 'Poll txid')
|
|
} catch (err) {
|
|
threw = true
|
|
}
|
|
return threw
|
|
},
|
|
{ label: 'hexToBytes length contract' }
|
|
)
|
|
})
|
|
|
|
test('poll pages conserve the remaining byte count', async () => {
|
|
await forAll(
|
|
() => {
|
|
const mode = Math.floor(rng() * 2)
|
|
return { mode, text: randomText(400) }
|
|
},
|
|
({ mode, text }) => {
|
|
const Page = mode === 0 ? PollOptionPage : PollVotePage
|
|
const handlerKey = mode === 0 ? 'memoPollOption' : 'memoPollVote'
|
|
const page = new Page({ [handlerKey]: {} })
|
|
page.setInput(text)
|
|
const limit = mode === 0
|
|
? MemoPollOption.MAX_OPTION_BYTES
|
|
: MemoPollVote.MAX_COMMENT_BYTES
|
|
return page.remainingCount() === limit - byteLength(text)
|
|
},
|
|
{ label: 'poll page remaining byte conservation' }
|
|
)
|
|
})
|
|
|
|
test('poll pages reject empty as validation and over-long as length without broadcasting', async () => {
|
|
await forAll(
|
|
() => {
|
|
const mode = Math.floor(rng() * 2)
|
|
const kind = Math.floor(rng() * 2) // 0 = empty, 1 = over-long
|
|
return { mode, kind }
|
|
},
|
|
async ({ mode, kind }) => {
|
|
const wallet = makeWallet()
|
|
const txid = randomTxid()
|
|
const Handler = mode === 0 ? MemoPollOption : MemoPollVote
|
|
const handler = new Handler({ wallet, pollTxid: txid })
|
|
const Page = mode === 0 ? PollOptionPage : PollVotePage
|
|
const page = new Page({
|
|
[mode === 0 ? 'memoPollOption' : 'memoPollVote']: handler
|
|
})
|
|
const limit = mode === 0
|
|
? MemoPollOption.MAX_OPTION_BYTES
|
|
: MemoPollVote.MAX_COMMENT_BYTES
|
|
const input = kind === 0 ? '' : 'x'.repeat(limit + 100)
|
|
const validationCode = mode === 0 ? 'poll_option_validation' : 'poll_vote_validation'
|
|
const lengthCode = mode === 0 ? 'poll_option_length' : 'poll_vote_length'
|
|
|
|
page.setInput(input)
|
|
const result = await page.submit()
|
|
|
|
if (wallet.broadcasts.length !== 0) return false
|
|
if (result.ok !== false) return false
|
|
return page.submitError === (kind === 0 ? validationCode : lengthCode)
|
|
},
|
|
{ label: 'poll page rejection classification' }
|
|
)
|
|
})
|