2026-10-04 02:32:59 +02:00
// The blocklist consumer (Decentralized.DNS/PROTOCOL-ADDENDUM-blocklist.md).
//
// No network. The rules are a pure reduction of one list address's history, so
// every case is a synthetic history. The last block proves the contract the
// roadmap asks for: a resolver with NO subscription is the pre-blocklist
// resolver, and a subscription to an EMPTY list changes nothing either.
import test from "node:test" ;
import assert from "node:assert/strict" ;
import { createHash } from "node:crypto" ;
import {
NETWORKS , KNOWN _OPS , BLOCK _REASONS , sha256 , addressToLockHex , addressToScripthash ,
reduceBlocklist , blocklistAddress , buildBlocklists , withBlockFlag , resolveName , buildIndexFromSnapshot ,
} from "../src/lib/resolver-web.js" ;
import * as legacy from "../src/lib/bns.js" ;
import { reportPayload , blockPayload , unblockPayload , buildListTx , LIST _MIN _CHANGE _SAT } from "../src/lib/blocklist-tx.js" ;
import { addressToLockingBytecode , addressToScripthash as libauthScripthash } from "../src/lib/chain.js" ;
import { InsufficientFundsError } from "../src/lib/register-tx.js" ;
const NET = NETWORKS . chipnet ;
const LIST = NET . blocklists [ 0 ] ;
const LOCK = addressToLockHex ( LIST . beacon ) ;
const OTHER _LOCK = addressToLockHex ( NET . operatorAddress ) ; // someone who is not the list
// ---------- fixture builders ----------
const hex = ( s ) => Buffer . from ( s , "utf8" ) . toString ( "hex" ) ;
const txid = ( n ) => String ( n ) . padStart ( 4 , "0" ) . repeat ( 16 ) ;
const opReturn = ( payload ) => {
const data = Buffer . from ( "BNS1" + JSON . stringify ( payload ) , "utf8" ) ;
const push = data . length <= 75 ? Buffer . from ( [ data . length ] ) : Buffer . from ( [ 0x4c , data . length ] ) ;
return "6a" + Buffer . concat ( [ push , data ] ) . toString ( "hex" ) ;
} ;
const out = ( n , lock ) => ( { n , value : 0.0001 , scriptPubKey : { hex : lock } } ) ;
const spend = ( id , vout = 0 ) => ( { txid : txid ( id ) , vout } ) ;
// A coin arriving at the list address from outside: gives the list something to spend.
const FUND = ( id ) => ( { txid : txid ( id ) , vin : [ { txid : "f" . repeat ( 64 ) , vout : 0 } ] , vout : [ out ( 0 , LOCK ) ] } ) ;
// An entry published by the list: spends a list coin, change back to the list.
const ENTRY = ( id , payload , spends ) => ( { txid : txid ( id ) , vin : spends , vout : [ out ( 0 , LOCK ) , { n : 1 , value : 0 , scriptPubKey : { hex : opReturn ( payload ) } } ] } ) ;
// The same payload from a stranger who merely pays dust to the list address.
const STRANGER = ( id , payload ) => ( { txid : txid ( id ) , vin : [ { txid : "e" . repeat ( 64 ) , vout : 0 } ] , vout : [ out ( 0 , LOCK ) , { n : 1 , value : 0 , scriptPubKey : { hex : opReturn ( payload ) } } , out ( 2 , OTHER _LOCK ) ] } ) ;
const RPT = ( names , r = "phishing" , t = "block" , extra = { } ) => ( { o : "RPT" , t , n : names , ... ( r ? { r } : { } ) , ... extra } ) ;
const BLK = ( n , p , r = "phishing" , extra = { } ) => ( { o : "BLK" , n , r , p : txid ( p ) , ... extra } ) ;
const UBK = ( n , p ) => ( { o : "UBK" , n , p : txid ( p ) } ) ;
function reduce ( entries ) {
const history = entries . map ( ( [ tx , height ] ) => ( { tx _hash : tx . txid , height } ) ) ;
const txs = new Map ( entries . map ( ( [ tx ] ) => [ tx . txid , tx ] ) ) ;
return reduceBlocklist ( { history , txs , address : LIST . beacon , source : LIST . name } ) ;
}
// ---------- primitives ----------
test ( "sha256 matches the platform implementation" , ( ) => {
for ( const len of [ 0 , 1 , 55 , 56 , 63 , 64 , 65 , 119 , 120 , 257 , 4096 ] ) {
const b = Buffer . alloc ( len , len % 251 ) ;
assert . equal ( Buffer . from ( sha256 ( new Uint8Array ( b ) ) ) . toString ( "hex" ) , createHash ( "sha256" ) . update ( b ) . digest ( "hex" ) , ` length ${ len } ` ) ;
}
} ) ;
test ( "address -> scripthash agrees with the pinned beacons and with libauth" , ( ) => {
assert . equal ( addressToScripthash ( NET . beacon ) , NET . beaconScripthash ) ;
assert . equal ( addressToScripthash ( NET . tldBeacon ) , NET . tldBeaconScripthash ) ;
for ( const l of NET . blocklists ) {
assert . equal ( addressToScripthash ( l . beacon ) , l . scripthash , "the table's scripthash must match its address" ) ;
assert . equal ( addressToScripthash ( l . beacon ) , libauthScripthash ( l . beacon ) ) ;
}
assert . throws ( ( ) => addressToLockHex ( LIST . beacon . slice ( 0 , - 1 ) + "x" ) , /checksum/ ) ;
assert . throws ( ( ) => addressToLockHex ( "qrrnwvuasq5fy63mdvp3umx5qfavtu7mnq950rdssw" ) , /prefix/ ) ;
} ) ;
test ( "the three opcodes exist in both codecs, and an RPT's name list parses" , ( ) => {
for ( const op of [ "RPT" , "BLK" , "UBK" ] ) { assert . ok ( KNOWN _OPS . has ( op ) ) ; assert . ok ( legacy . KNOWN _OPS . has ( op ) ) ; }
assert . deepEqual ( [ ... KNOWN _OPS ] . sort ( ) , [ ... legacy . KNOWN _OPS ] . sort ( ) ) ;
assert . deepEqual ( legacy . parseOpReturn ( opReturn ( RPT ( [ "a.x" , "b.x" ] ) ) ) . n , [ "a.x" , "b.x" ] ) ;
assert . equal ( legacy . parseOpReturn ( opReturn ( { o : "RPT" , t : "block" , n : [ 1 , 2 ] } ) ) , null , "a name list of non-strings is not a payload" ) ;
} ) ;
// ---------- the rules ----------
test ( "an empty list flags nothing" , ( ) => {
assert . equal ( reduce ( [ ] ) . size , 0 ) ;
assert . equal ( reduce ( [ [ FUND ( 1 ) , 100 ] ] ) . size , 0 , "a funding payment is not an entry" ) ;
} ) ;
test ( "a block with a valid report flags the name" , ( ) => {
const flags = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] , "phishing" , "block" , { e : "sia://evidence" } ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 2 ) ] ) , 101 ] ] ) ;
const f = flags . get ( "scam.x" ) ;
assert . equal ( f . reason , "phishing" ) ;
assert . equal ( f . reportTxid , txid ( 2 ) ) ;
assert . equal ( f . txid , txid ( 3 ) ) ;
assert . equal ( f . source , LIST . name ) ;
assert . equal ( f . evidence , "sia://evidence" , "the report's evidence is carried when the flag has none" ) ;
} ) ;
test ( "a block whose report is missing is ignored" , ( ) => {
assert . equal ( reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 1 ) ] ) , 101 ] ] ) . size , 0 ) ;
} ) ;
test ( "a block whose reason differs from its report is ignored" , ( ) => {
const flags = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] , "phishing" ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 , "malware" ) , [ spend ( 2 ) ] ) , 102 ] ] ) ;
assert . equal ( flags . size , 0 ) ;
} ) ;
test ( "a block for a name its report does not cover is ignored" , ( ) => {
const flags = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "innocent.x" , 2 ) , [ spend ( 2 ) ] ) , 102 ] ] ) ;
assert . equal ( flags . size , 0 ) ;
} ) ;
test ( "reasons outside the fixed vocabulary are never applied" , ( ) => {
assert . equal ( BLOCK _REASONS . has ( "offensive" ) , false ) ;
const flags = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "x.x" ] , "offensive" ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "x.x" , 2 , "offensive" ) , [ spend ( 2 ) ] ) , 102 ] ] ) ;
assert . equal ( flags . size , 0 ) ;
} ) ;
test ( "one report can flag several names" , ( ) => {
const flags = reduce ( [
[ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "a.x" , "b.x" , "legacy" ] , "malware" ) , [ spend ( 1 ) ] ) , 101 ] ,
[ ENTRY ( 3 , BLK ( "a.x" , 2 , "malware" ) , [ spend ( 2 ) ] ) , 102 ] , [ ENTRY ( 4 , BLK ( "b.x" , 2 , "malware" ) , [ spend ( 3 ) ] ) , 102 ] , [ ENTRY ( 5 , BLK ( "legacy" , 2 , "malware" ) , [ spend ( 4 ) ] ) , 102 ] ,
] ) ;
assert . deepEqual ( [ ... flags . keys ( ) ] . sort ( ) , [ "a.x" , "b.x" , "legacy.bch" ] , "a bare .bch label is flagged under its canonical name" ) ;
} ) ;
// ---------- authority ----------
test ( "entries from someone who only PAYS the list address are ignored" , ( ) => {
// The stranger's transactions are in the address history (they pay it dust), and well-formed.
const flags = reduce ( [ [ FUND ( 1 ) , 100 ] , [ STRANGER ( 2 , RPT ( [ "victim.x" ] ) ) , 101 ] , [ STRANGER ( 3 , BLK ( "victim.x" , 2 ) ) , 102 ] ] ) ;
assert . equal ( flags . size , 0 ) ;
} ) ;
test ( "a stranger's block cannot ride on the list's own report, nor the reverse" , ( ) => {
const strangerBlock = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "victim.x" ] ) , [ spend ( 1 ) ] ) , 101 ] , [ STRANGER ( 3 , BLK ( "victim.x" , 2 ) ) , 102 ] ] ) ;
assert . equal ( strangerBlock . size , 0 ) ;
const strangerReport = reduce ( [ [ FUND ( 1 ) , 100 ] , [ STRANGER ( 2 , RPT ( [ "victim.x" ] ) ) , 101 ] , [ ENTRY ( 3 , BLK ( "victim.x" , 2 ) , [ spend ( 1 ) ] ) , 102 ] ] ) ;
assert . equal ( strangerReport . size , 0 , "the list's block needs the list's report" ) ;
} ) ;
test ( "a stranger cannot unblock" , ( ) => {
const flags = reduce ( [
[ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 2 ) ] ) , 102 ] ,
[ STRANGER ( 4 , RPT ( [ "scam.x" ] , null , "unblock" ) ) , 103 ] , [ STRANGER ( 5 , UBK ( "scam.x" , 4 ) ) , 104 ] ,
] ) ;
assert . equal ( flags . get ( "scam.x" ) . reason , "phishing" ) ;
} ) ;
// ---------- unblock and ordering ----------
test ( "an unblock with a valid unblock report lifts the flag; latest entry wins" , ( ) => {
const base = [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 2 ) ] ) , 102 ] ] ;
const lifted = reduce ( [ ... base , [ ENTRY ( 4 , RPT ( [ "scam.x" ] , null , "unblock" ) , [ spend ( 3 ) ] ) , 110 ] , [ ENTRY ( 5 , UBK ( "scam.x" , 4 ) , [ spend ( 4 ) ] ) , 111 ] ] ) ;
assert . equal ( lifted . size , 0 ) ;
const again = reduce ( [ ... base , [ ENTRY ( 4 , RPT ( [ "scam.x" ] , null , "unblock" ) , [ spend ( 3 ) ] ) , 110 ] , [ ENTRY ( 5 , UBK ( "scam.x" , 4 ) , [ spend ( 4 ) ] ) , 111 ] ,
[ ENTRY ( 6 , RPT ( [ "scam.x" ] , "malware" ) , [ spend ( 5 ) ] ) , 120 ] , [ ENTRY ( 7 , BLK ( "scam.x" , 6 , "malware" ) , [ spend ( 6 ) ] ) , 121 ] ] ) ;
assert . equal ( again . get ( "scam.x" ) . reason , "malware" ) ;
} ) ;
test ( "an unblock cannot cite a block report, or no report" , ( ) => {
const base = [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 2 ) ] ) , 102 ] ] ;
assert . equal ( reduce ( [ ... base , [ ENTRY ( 5 , UBK ( "scam.x" , 2 ) , [ spend ( 3 ) ] ) , 111 ] ] ) . size , 1 , "the block report does not authorise an unblock" ) ;
assert . equal ( reduce ( [ ... base , [ ENTRY ( 5 , UBK ( "scam.x" , 77 ) , [ spend ( 3 ) ] ) , 111 ] ] ) . size , 1 ) ;
} ) ;
test ( "report and block in one block apply whatever their txid order" , ( ) => {
// The BLK's txid sorts BEFORE its report's. It spends the report's change, so causal order fixes it —
// and the report is looked up by txid, so it would be found even without that.
const chained = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 9 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 105 ] , [ ENTRY ( 2 , BLK ( "scam.x" , 9 ) , [ spend ( 9 ) ] ) , 105 ] ] ) ;
assert . equal ( chained . get ( "scam.x" ) . reportTxid , txid ( 9 ) ) ;
const unchained = reduce ( [ [ FUND ( 1 ) , 100 ] , [ FUND ( 8 ) , 100 ] , [ ENTRY ( 9 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 105 ] , [ ENTRY ( 2 , BLK ( "scam.x" , 9 ) , [ spend ( 8 ) ] ) , 105 ] ] ) ;
assert . equal ( unchained . get ( "scam.x" ) . reportTxid , txid ( 9 ) ) ;
} ) ;
test ( "unconfirmed entries apply, like every other BNS message" , ( ) => {
const flags = reduce ( [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 0 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 2 ) ] ) , - 1 ] ] ) ;
assert . equal ( flags . get ( "scam.x" ) . reason , "phishing" ) ;
} ) ;
// ---------- where a list is read from ----------
test ( "a list's address comes from its name's bl record, else from the table" , ( ) => {
const other = NET . operatorAddress ;
assert . equal ( blocklistAddress ( LIST , null ) , LIST . beacon ) ;
assert . equal ( blocklistAddress ( LIST , new Map ( [ [ LIST . name , { records : { bl : other } } ] ] ) ) , other , "the name can rotate the address" ) ;
assert . equal ( blocklistAddress ( LIST , new Map ( [ [ LIST . name , { records : { bl : "not-an-address" } } ] ] ) ) , LIST . beacon , "a malformed record falls back" ) ;
assert . equal ( blocklistAddress ( LIST , new Map ( [ [ LIST . name , { records : { } } ] ] ) ) , LIST . beacon ) ;
} ) ;
// ---------- the write side ----------
test ( "payload builders enforce the vocabulary, the report link and the size" , ( ) => {
assert . throws ( ( ) => reportPayload ( { type : "block" , names : [ "a.x" ] , reason : "offensive" } ) , /reason must be one of/ ) ;
assert . throws ( ( ) => reportPayload ( { type : "ban" , names : [ "a.x" ] , reason : "phishing" } ) , /block.*unblock/ ) ;
assert . throws ( ( ) => reportPayload ( { type : "block" , names : [ ] , reason : "phishing" } ) , /at least one name/ ) ;
assert . throws ( ( ) => blockPayload ( { name : "a.x" , reason : "phishing" , reportTxid : "nope" } ) , /64-hex/ ) ;
assert . throws ( ( ) => reportPayload ( { type : "block" , names : [ "a.x" ] , reason : "phishing" , evidence : "x" . repeat ( 200 ) } ) , /too large/ ) ;
const text = ( b ) => Buffer . from ( b ) . toString ( "utf8" ) ;
assert . equal ( text ( reportPayload ( { type : "unblock" , names : [ "A.X" ] } ) ) , 'BNS1{"o":"RPT","t":"unblock","n":["a.x"]}' ) ;
assert . equal ( text ( blockPayload ( { name : "scam.x" , reason : "phishing" , reportTxid : txid ( 2 ) } ) ) , ` BNS1{"o":"BLK","n":"scam.x","r":"phishing","p":" ${ txid ( 2 ) } "} ` ) ;
assert . equal ( text ( unblockPayload ( { name : "scam.x" , reportTxid : txid ( 4 ) } ) ) , ` BNS1{"o":"UBK","n":"scam.x","p":" ${ txid ( 4 ) } "} ` ) ;
} ) ;
test ( "buildListTx spends from the list and returns its change to the list" , ( ) => {
const lock = addressToLockingBytecode ( LIST . beacon ) ;
const coin = { txid : txid ( 1 ) , vout : 0 , satoshis : 20_000 n , address : LIST . beacon , lockingBytecode : lock } ;
const foreign = { txid : txid ( 5 ) , vout : 0 , satoshis : 900_000 n , address : NET . operatorAddress , lockingBytecode : addressToLockingBytecode ( NET . operatorAddress ) } ;
const built = buildListTx ( { payload : reportPayload ( { type : "block" , names : [ "scam.x" ] , reason : "phishing" } ) , listAddress : LIST . beacon , utxos : [ foreign , coin ] , addressToLockingBytecode } ) ;
assert . equal ( built . transaction . inputs . length , 1 ) ;
assert . equal ( Buffer . from ( built . transaction . inputs [ 0 ] . outpointTransactionHash ) . toString ( "hex" ) , txid ( 1 ) , "a coin from another address is never used: it would not prove authority" ) ;
assert . equal ( Buffer . from ( built . transaction . outputs [ 0 ] . lockingBytecode ) . toString ( "hex" ) , LOCK ) ;
assert . equal ( built . changeSats + built . chainFeeSats , 20_000 n ) ;
assert . ok ( built . changeSats >= LIST _MIN _CHANGE _SAT ) ;
const next = built . next ( txid ( 2 ) ) ;
assert . deepEqual ( [ next . txid , next . vout , next . satoshis ] , [ txid ( 2 ) , 0 , built . changeSats ] , "the change is the next entry's input" ) ;
assert . throws ( ( ) => buildListTx ( { payload : new Uint8Array ( 10 ) , listAddress : LIST . beacon , utxos : [ foreign ] , addressToLockingBytecode } ) , InsufficientFundsError ) ;
assert . throws ( ( ) => buildListTx ( { payload : new Uint8Array ( 10 ) , listAddress : LIST . beacon , utxos : [ { ... coin , satoshis : 700 n } ] , addressToLockingBytecode } ) , InsufficientFundsError ) ;
} ) ;
test ( "what the builders write, the reducer accepts" , ( ) => {
// Round trip through the real payload encoders rather than hand-written JSON.
const asEntry = ( id , payloadBytes , spends ) => {
const data = Buffer . from ( payloadBytes ) ;
const push = data . length <= 75 ? Buffer . from ( [ data . length ] ) : Buffer . from ( [ 0x4c , data . length ] ) ;
return { txid : txid ( id ) , vin : spends , vout : [ out ( 0 , LOCK ) , { n : 1 , value : 0 , scriptPubKey : { hex : "6a" + Buffer . concat ( [ push , data ] ) . toString ( "hex" ) } } ] } ;
} ;
const flags = reduce ( [
[ FUND ( 1 ) , 100 ] ,
[ asEntry ( 2 , reportPayload ( { type : "block" , names : [ "scam.x" ] , reason : "scam-financial" , evidence : "sia://abc" } ) , [ spend ( 1 ) ] ) , 101 ] ,
[ asEntry ( 3 , blockPayload ( { name : "scam.x" , reason : "scam-financial" , reportTxid : txid ( 2 ) } ) , [ spend ( 2 ) ] ) , 101 ] ,
] ) ;
assert . equal ( flags . get ( "scam.x" ) . reason , "scam-financial" ) ;
} ) ;
// ---------- the contract: no subscription, or an empty list, changes nothing ----------
const cert = ( name , category ) => ( { n : 0 , value : 0.00001 , scriptPubKey : { hex : "76a914" + "11" . repeat ( 20 ) + "88ac" , address : "bchtest:owner" } , tokenData : { category , amount : "0" , nft : { capability : "none" , commitment : hex ( name ) } } } ) ;
2026-10-04 16:13:04 +02:00
const REG = ( id , name , category ) => ( { txid : txid ( id ) , vin : [ { txid : category , vout : 0 } ] , vout : [ cert ( name , category ) , { n : 1 , value : 0 , scriptPubKey : { hex : opReturn ( { o : "REG" , n : name , r : { u : "https://site.example" } } ) } } ] } ) ;
2026-10-04 02:32:59 +02:00
// A fake electrum client: the name beacon, the TLD beacon (empty), and the list address.
function fakeClient ( { names , list = [ ] , failList = false } ) {
const byScripthash = new Map ( [
[ NET . beaconScripthash , names ] ,
[ NET . tldBeaconScripthash , [ ] ] ,
[ addressToScripthash ( LIST . beacon ) , list ] ,
] ) ;
const txs = new Map ( [ ... names , ... list ] . map ( ( [ tx ] ) => [ tx . txid , tx ] ) ) ;
const calls = [ ] ;
return {
calls ,
close ( ) { } ,
async call ( method , [ arg ] ) {
calls . push ( method + ":" + String ( arg ) . slice ( 0 , 8 ) ) ;
if ( method === "blockchain.scripthash.get_history" ) {
if ( failList && arg === addressToScripthash ( LIST . beacon ) ) throw new Error ( "server refused" ) ;
return ( byScripthash . get ( arg ) || [ ] ) . map ( ( [ tx , height ] ) => ( { tx _hash : tx . txid , height } ) ) ;
}
if ( method === "blockchain.transaction.get" ) return txs . get ( arg ) ;
throw new Error ( "unexpected " + method ) ;
} ,
} ;
}
const NAMES = [ [ REG ( 20 , "good.x" , "a" . repeat ( 64 ) ) , 90 ] , [ REG ( 21 , "scam.x" , "b" . repeat ( 64 ) ) , 91 ] ] ;
const FLAGGED = [ [ FUND ( 1 ) , 100 ] , [ ENTRY ( 2 , RPT ( [ "scam.x" ] ) , [ spend ( 1 ) ] ) , 101 ] , [ ENTRY ( 3 , BLK ( "scam.x" , 2 ) , [ spend ( 2 ) ] ) , 102 ] ] ;
test ( "no subscription: resolveName is exactly the pre-blocklist resolver" , async ( ) => {
const history = NAMES . map ( ( [ tx , height ] ) => ( { tx _hash : tx . txid , height } ) ) ;
const before = buildIndexFromSnapshot ( { snapshot : { history , txs : Object . fromEntries ( NAMES . map ( ( [ tx ] ) => [ tx . txid , tx ] ) ) } } ) ;
for ( const blocklists of [ [ ] , false ] ) {
const client = fakeClient ( { names : NAMES , list : FLAGGED } ) ;
for ( const n of [ "good.x" , "scam.x" ] ) {
assert . deepEqual ( await resolveName ( n , { client , blocklists } ) , before . get ( n ) ) ;
}
assert . equal ( await resolveName ( "absent.x" , { client , blocklists } ) , null ) ;
assert . ok ( ! client . calls . some ( ( c ) => c . endsWith ( addressToScripthash ( LIST . beacon ) . slice ( 0 , 8 ) ) ) , "the list address is never even queried" ) ;
}
} ) ;
test ( "default subscription to an EMPTY list: same answers, one extra history call" , async ( ) => {
const unsubscribed = fakeClient ( { names : NAMES } ) ;
const subscribed = fakeClient ( { names : NAMES , list : [ ] } ) ;
for ( const n of [ "good.x" , "scam.x" , "absent.x" ] ) {
assert . deepEqual ( await resolveName ( n , { client : subscribed } ) , await resolveName ( n , { client : unsubscribed , blocklists : [ ] } ) ) ;
}
const good = await resolveName ( "good.x" , { client : subscribed } ) ;
assert . equal ( "blocked" in good , false , "an unflagged entry carries no blocklist field at all" ) ;
const listCalls = subscribed . calls . filter ( ( c ) => c === "blockchain.scripthash.get_history:" + addressToScripthash ( LIST . beacon ) . slice ( 0 , 8 ) ) ;
assert . equal ( listCalls . length , 3 , "one history call per resolution, and no transaction fetch for an empty list" ) ;
} ) ;
test ( "a flagged name resolves with the flag attached; others are untouched" , async ( ) => {
const client = fakeClient ( { names : NAMES , list : FLAGGED } ) ;
const scam = await resolveName ( "scam.x" , { client } ) ;
assert . equal ( scam . blocked , true ) ;
assert . equal ( scam . blockReason , "phishing" ) ;
assert . equal ( scam . blockReportTxid , txid ( 2 ) ) ;
assert . equal ( scam . blockedBy , LIST . name ) ;
assert . deepEqual ( scam . records , { u : "https://site.example" } , "the name still resolves: what to do with the flag is the client's policy" ) ;
assert . equal ( "blocked" in ( await resolveName ( "good.x" , { client } ) ) , false ) ;
} ) ;
test ( "a list that cannot be read never stops a name from resolving" , async ( ) => {
const client = fakeClient ( { names : NAMES , list : FLAGGED , failList : true } ) ;
const scam = await resolveName ( "scam.x" , { client } ) ;
assert . equal ( scam . category , "b" . repeat ( 64 ) ) ;
assert . equal ( "blocked" in scam , false ) ;
} ) ;
test ( "buildBlocklists: union over lists, and withBlockFlag never mutates the index" , async ( ) => {
const client = fakeClient ( { names : NAMES , list : FLAGGED } ) ;
const flags = await buildBlocklists ( { client , lists : [ LIST , { name : "second-list.x" , beacon : LIST . beacon } ] } ) ;
assert . equal ( flags . get ( "scam.x" ) . sources . length , 2 , "two lists flag it: both are kept, the first is shown" ) ;
assert . equal ( flags . get ( "scam.x" ) . source , LIST . name ) ;
const entry = { name : "scam.x" , records : { } } ;
const flagged = withBlockFlag ( entry , flags ) ;
assert . equal ( flagged . blocked , true ) ;
assert . equal ( "blocked" in entry , false ) ;
assert . equal ( withBlockFlag ( { name : "good.x" } , flags ) . blocked , undefined ) ;
assert . equal ( ( await buildBlocklists ( { client , lists : [ ] } ) ) . size , 0 ) ;
} ) ;