registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
// The name registry covenant, run in libauth's BCH virtual machine.
// (contracts/name-registry.cash, contracts/commit-box.cash)
//
// No network, no coins. Every path is built with the library and executed by
// the VM, then broken one rule at a time: each negative case re-signs the
// tampered transaction, so it fails because the CONTRACT refuses it and not
// because a signature went stale.
//
// The prices are checked against registry-lifecycle.js to the satoshi. That
// file is the reference; if the two ever disagree the contract is wrong.
//
// This is a draft contract and these tests are not a review. They show the
// paths behave as designed, not that no other path exists.
import test from "node:test" ;
import assert from "node:assert/strict" ;
import * as L from "@bitauth/libauth" ;
import {
registryTerms , registryRedeem , registryLock , registryCode , registryState , parseRegistryState , STATE _BYTES ,
2026-10-04 16:53:56 +02:00
commitBoxLock , commitBoxRedeem , commitHashFor , takeoverQuote , signMigrationApproval , migratedLock , registryPhase , COMMIT _SALT _BYTES ,
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
buildRegistryRenewTx , buildRegistryTransferTx , buildRegistryTakeoverTx , buildRegistryReleaseTx , buildRegistryMigrateTx ,
} from "../src/lib/registry-covenant.js" ;
import {
BLOCKS _PER _YEAR , GRACE _BLOCKS , PHASE , takeoverPriceSats , splitTakeover , renewalPriceSats , phaseAt ,
} from "../src/lib/registry-lifecycle.js" ;
2026-10-04 16:53:56 +02:00
import { unlockScript , assemble , p2sh32Lock , selectorsOf , PUBKEY , SIG } from "../src/lib/covenant.js" ;
import { NAME _REGISTRY } from "../src/lib/name-registry-artifact.js" ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
const { hexToBin , binToHex , hash160 , secp256k1 , encodeLockingBytecodeP2pkh , generatePrivateKey , flattenBinArray } = L ;
// Run under the rules live today and under the next upgrade's.
const VMS = [ [ "BCH 2025" , L . createVirtualMachineBch2025 ( ) ] , [ "BCH 2026" , L . createVirtualMachineBch2026 ( ) ] ] ;
const verdict = ( built ) => VMS . map ( ( [ label , vm ] ) => [ label , vm . verify ( { transaction : built . transaction , sourceOutputs : built . sourceOutputs } ) ] ) ;
const accepts = ( built , what ) => { for ( const [ label , r ] of verdict ( built ) ) assert . equal ( r , true , ` ${ what } — ${ label } : ${ r } ` ) ; } ;
// A negative case must be refused by the VIRTUAL MACHINE. If the library
// throws before a transaction exists, the contract was never asked, and the
// case proves nothing about it — so those are counted, and the last test
// pins exactly which cases are allowed to end that way.
const refusedByLibrary = [ ] ;
let refusedByVm = 0 ;
const rejects = ( make , what ) => {
let built ;
try { built = make ( ) ; } catch ( e ) { refusedByLibrary . push ( what ) ; return ; }
for ( const [ label , r ] of verdict ( built ) ) assert . notEqual ( r , true , ` ${ what } — ${ label } accepted it ` ) ;
refusedByVm ++ ;
} ;
// ---------- cast ----------
const person = ( ) => {
const priv = generatePrivateKey ( ( ) => crypto . getRandomValues ( new Uint8Array ( 32 ) ) ) ;
const pub = secp256k1 . derivePublicKeyCompressed ( priv ) ;
const pkh = hash160 ( pub ) ;
const lock = encodeLockingBytecodeP2pkh ( pkh ) ;
return { priv , pub , pkh : binToHex ( pkh ) , lock , lockHex : binToHex ( lock ) } ;
} ;
const owner = person ( ) , taker = person ( ) , buyer = person ( ) , steward = person ( ) , fund = person ( ) , stranger = person ( ) ;
const keyring = ( ... people ) => ( lockHex ) => { const p = people . find ( ( x ) => x . lockHex === lockHex ) ; return p ? { privateKey : p . priv , publicKey : p . pub } : null ; } ;
const fakeTxid = ( n ) => n . toString ( 16 ) . padStart ( 64 , "0" ) ;
const coin = ( id , who , sats = 500_000_000 n ) => ( { txid : fakeTxid ( id ) , vout : 0 , satoshis : sats , lockingBytecode : who . lock } ) ;
const TIER = 3 ;
const terms = registryTerms ( { tier : TIER , fundLock : fund . lock , stewardPk : steward . pub } ) ;
const NAME = "covenant.x" ;
const CATEGORY = "ab" . repeat ( 32 ) ;
const EXPIRY = 900_000 + BLOCKS _PER _YEAR ;
const IDLE _END = EXPIRY + GRACE _BLOCKS ;
const state0 = { ownerPkh : owner . pkh , expiry : EXPIRY } ;
const certToken = ( name = NAME ) => ( { category : CATEGORY , amount : 0 n , nft : { capability : "none" , commitment : binToHex ( new TextEncoder ( ) . encode ( name ) ) } } ) ;
const covUtxo = ( state = state0 , name = NAME ) => ( { txid : fakeTxid ( 1 ) , vout : 0 , satoshis : 1000 n , lockingBytecode : registryLock ( terms , state ) , token : certToken ( name ) } ) ;
2026-10-04 16:53:56 +02:00
const SALT = hexToBin ( "00112233445566778899aabbccddeeff" . repeat ( 2 ) ) ; // 32 bytes: the contract requires exactly that
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
const boxUtxo = ( who = taker , name = NAME , salt = SALT ) => ( { txid : fakeTxid ( 7 ) , vout : 0 , satoshis : 1000 n , lockingBytecode : commitBoxLock ( { ownerPkh : who . pkh , commitHash : commitHashFor ( name , salt ) } ) } ) ;
const outLockHex = ( built , i ) => binToHex ( built . transaction . outputs [ i ] . lockingBytecode ) ;
const setOut = ( i , patch ) => ( tx ) => ( { ... tx , outputs : tx . outputs . map ( ( o , k ) => ( k === i ? { ... o , ... patch ( o ) } : o ) ) } ) ;
// ---------- the address is the state ----------
test ( "state prefix: 26 bytes the contract can swap, and nothing else moves" , ( ) => {
const redeem = registryRedeem ( terms , state0 ) ;
assert . equal ( binToHex ( redeem . slice ( 0 , 1 ) ) , "14" ) ;
assert . equal ( binToHex ( redeem . slice ( 21 , 22 ) ) , "04" ) ;
assert . deepEqual ( parseRegistryState ( redeem ) , state0 ) ;
assert . equal ( binToHex ( redeem . slice ( STATE _BYTES ) ) , binToHex ( registryCode ( terms ) ) , "CODE is identical for every name in the tier" ) ;
const other = registryRedeem ( terms , { ownerPkh : taker . pkh , expiry : EXPIRY + 5 } ) ;
assert . equal ( binToHex ( other . slice ( STATE _BYTES ) ) , binToHex ( redeem . slice ( STATE _BYTES ) ) ) ;
assert . notEqual ( binToHex ( registryLock ( terms , state0 ) ) , binToHex ( registryLock ( terms , { ... state0 , expiry : EXPIRY + 1 } ) ) , "a different expiry is a different address" ) ;
assert . throws ( ( ) => registryState ( { ownerPkh : "00" , expiry : 1 } ) ) ;
} ) ;
// ---------- renew ----------
const renew = ( o = { } ) => buildRegistryRenewTx ( { cov : covUtxo ( ) , terms , state : state0 , years : 1 , coins : [ coin ( 2 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) , ... o } ) ;
test ( "renew: the owner extends the name and pays the fund the tier's price" , ( ) => {
for ( const years of [ 1 , 3 ] ) {
const built = renew ( { years } ) ;
accepts ( built , ` renew ${ years } y ` ) ;
assert . equal ( outLockHex ( built , 0 ) , binToHex ( registryLock ( terms , { ownerPkh : owner . pkh , expiry : EXPIRY + years * BLOCKS _PER _YEAR } ) ) ) ;
assert . equal ( built . transaction . outputs [ 1 ] . valueSatoshis , renewalPriceSats ( TIER , years ) , "the price is the reference model's" ) ;
assert . equal ( binToHex ( built . transaction . outputs [ 1 ] . lockingBytecode ) , fund . lockHex ) ;
assert . deepEqual ( built . state , { ownerPkh : owner . pkh , expiry : EXPIRY + years * BLOCKS _PER _YEAR } ) ;
}
} ) ;
test ( "renew: an expired name renews the same way (reclaim), from its old expiry" , ( ) => {
// No height is involved at all: the owner can reclaim for as long as nobody has taken the name.
accepts ( renew ( { years : 2 } ) , "reclaim" ) ;
} ) ;
test ( "renew is refused when a rule is broken" , ( ) => {
rejects ( ( ) => buildRegistryRenewTx ( { cov : covUtxo ( ) , terms , state : state0 , coins : [ coin ( 2 , stranger ) ] , changeLock : stranger . lock , keyFor : ( h ) => keyring ( stranger ) ( h ) ? ? { privateKey : stranger . priv , publicKey : stranger . pub } } ) , "a stranger's signature" ) ;
rejects ( ( ) => renew ( { tamper : setOut ( 1 , ( o ) => ( { valueSatoshis : o . valueSatoshis - 1 n } ) ) } ) , "one satoshi short" ) ;
rejects ( ( ) => renew ( { tamper : setOut ( 1 , ( ) => ( { lockingBytecode : owner . lock } ) ) } ) , "the payment goes to the owner instead of the fund" ) ;
rejects ( ( ) => renew ( { years : 1 , tamper : setOut ( 0 , ( ) => ( { lockingBytecode : registryLock ( terms , { ownerPkh : owner . pkh , expiry : EXPIRY + 2 * BLOCKS _PER _YEAR } ) } ) ) } ) , "two years claimed, one paid" ) ;
rejects ( ( ) => renew ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : owner . lock } ) ) } ) , "the certificate leaves the covenant" ) ;
rejects ( ( ) => renew ( { tamper : setOut ( 0 , ( o ) => ( { token : { ... o . token , nft : { ... o . token . nft , commitment : new TextEncoder ( ) . encode ( "other.x" ) } } } ) ) } ) , "the certificate's name is changed" ) ;
rejects ( ( ) => renew ( { tamper : setOut ( 0 , ( ) => ( { token : undefined } ) ) } ) , "the certificate is dropped" ) ;
rejects ( ( ) => renew ( { tamper : setOut ( 0 , ( ) => ( { valueSatoshis : 999 n } ) ) } ) , "the certificate's dust is skimmed" ) ;
rejects ( ( ) => renew ( { years : 0 } ) , "zero years" ) ;
} ) ;
// ---------- transfer ----------
const transfer = ( o = { } ) => buildRegistryTransferTx ( { cov : covUtxo ( ) , terms , state : state0 , newOwnerPkh : buyer . pkh , coins : [ coin ( 2 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) , ... o } ) ;
test ( "transfer: the owner hands the name over; expiry is untouched" , ( ) => {
const built = transfer ( ) ;
accepts ( built , "transfer" ) ;
assert . equal ( outLockHex ( built , 0 ) , binToHex ( registryLock ( terms , { ownerPkh : buyer . pkh , expiry : EXPIRY } ) ) ) ;
accepts ( transfer ( { newOwnerPkh : owner . pkh } ) , "transfer to self — the carrier for a records update" ) ;
} ) ;
test ( "transfer cannot be used to walk the certificate out, or by anyone else" , ( ) => {
rejects ( ( ) => transfer ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : buyer . lock } ) ) } ) , "certificate sent to a plain address" ) ;
rejects ( ( ) => transfer ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : registryLock ( terms , { ownerPkh : buyer . pkh , expiry : EXPIRY + BLOCKS _PER _YEAR } ) } ) ) } ) , "a free year smuggled into a transfer" ) ;
rejects ( ( ) => buildRegistryTransferTx ( { cov : covUtxo ( ) , terms , state : state0 , newOwnerPkh : stranger . pkh , coins : [ coin ( 2 , stranger ) ] , changeLock : stranger . lock , keyFor : ( ) => ( { privateKey : stranger . priv , publicKey : stranger . pub } ) } ) , "a stranger signs" ) ;
} ) ;
// ---------- takeover ----------
const takeover = ( height , o = { } ) => buildRegistryTakeoverTx ( { cov : covUtxo ( ) , terms , state : state0 , name : NAME , height , newOwnerPkh : taker . pkh , salt : SALT , box : boxUtxo ( ) , coins : [ coin ( 3 , taker , 50_000_000_000 n ) ] , changeLock : taker . lock , keyFor : keyring ( taker ) , ... o } ) ;
test ( "takeover price and split equal the reference model at every point of the glide" , ( ) => {
const heights = [ EXPIRY , EXPIRY + 1 , EXPIRY + Math . floor ( GRACE _BLOCKS / 3 ) , EXPIRY + Math . floor ( GRACE _BLOCKS / 2 ) , IDLE _END - 1 , IDLE _END , IDLE _END + 1 , IDLE _END + 5 * BLOCKS _PER _YEAR ] ;
for ( const height of heights ) {
const model = takeoverPriceSats ( { height , expiryHeight : EXPIRY , idleEndHeight : IDLE _END , tier : TIER } ) ;
const split = splitTakeover ( { amountSats : model , phase : phaseAt ( { height , expiryHeight : EXPIRY , idleEndHeight : IDLE _END } ) } ) ;
const quote = takeoverQuote ( terms , state0 , height ) ;
assert . equal ( quote . priceSats , model , ` price at ${ height } ` ) ;
assert . equal ( quote . ownerSats , split . ownerSats , ` owner share at ${ height } ` ) ;
assert . equal ( quote . fundSats , split . fundSats , ` fund share at ${ height } ` ) ;
const built = takeover ( height ) ;
accepts ( built , ` takeover at ${ height } ` ) ;
assert . equal ( built . transaction . outputs [ 1 ] . valueSatoshis , split . fundSats ) ;
assert . equal ( outLockHex ( built , 0 ) , binToHex ( registryLock ( terms , { ownerPkh : taker . pkh , expiry : height + BLOCKS _PER _YEAR } ) ) , "one fresh year from the takeover height" ) ;
rejects ( ( ) => takeover ( height , { tamper : setOut ( 1 , ( o ) => ( { valueSatoshis : o . valueSatoshis - 1 n } ) ) } ) , ` fund underpaid by one satoshi at ${ height } ` ) ;
if ( split . ownerSats > 0 n ) {
assert . equal ( built . transaction . outputs [ 2 ] . valueSatoshis , split . ownerSats ) ;
assert . equal ( outLockHex ( built , 2 ) , owner . lockHex , "the previous owner is paid at their key" ) ;
rejects ( ( ) => takeover ( height , { tamper : setOut ( 2 , ( o ) => ( { valueSatoshis : o . valueSatoshis - 1 n } ) ) } ) , ` previous owner underpaid at ${ height } ` ) ;
rejects ( ( ) => takeover ( height , { tamper : setOut ( 2 , ( ) => ( { lockingBytecode : taker . lock } ) ) } ) , ` previous owner's share kept by the taker at ${ height } ` ) ;
} else {
assert . equal ( registryPhase ( terms , state0 , height ) , PHASE . open , "no owner share only once the grace period is over" ) ;
}
}
} ) ;
test ( "an ACTIVE name cannot be taken at any price" , ( ) => {
assert . equal ( takeoverQuote ( terms , state0 , EXPIRY - 1 ) , null ) ;
assert . throws ( ( ) => takeover ( EXPIRY - 1 ) , /ACTIVE/ ) ;
// Build a valid takeover, then claim an earlier height: the locktime check must refuse it.
rejects ( ( ) => takeover ( EXPIRY , { tamper : ( tx ) => ( { ... tx , locktime : EXPIRY - 1 } ) } ) , "locktime before expiry" ) ;
rejects ( ( ) => takeover ( EXPIRY , { tamper : ( tx ) => ( { ... tx , locktime : 0 } ) } ) , "no locktime" ) ;
rejects ( ( ) => takeover ( EXPIRY , { tamper : ( tx ) => ( { ... tx , locktime : 1_800_000_000 } ) } ) , "a timestamp in place of a height" ) ;
} ) ;
test ( "a taker cannot claim a later height than the locktime to pay less" , ( ) => {
// Price for IDLE_END (the floor) with a transaction that says it is only at EXPIRY.
const floorQuote = takeoverQuote ( terms , state0 , IDLE _END ) ;
rejects ( ( ) => takeover ( EXPIRY , { tamper : ( tx ) => ( { ... tx , outputs : tx . outputs . map ( ( o , i ) => ( i === 1 ? { ... o , valueSatoshis : floorQuote . fundSats } : o ) ) } ) } ) , "floor price paid during grace" ) ;
rejects ( ( ) => takeover ( EXPIRY , { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : registryLock ( terms , { ownerPkh : taker . pkh , expiry : EXPIRY + 3 * BLOCKS _PER _YEAR } ) } ) ) } ) , "three years claimed for the price of one" ) ;
} ) ;
test ( "takeover needs the taker's own matured commit for this name" , ( ) => {
accepts ( takeover ( EXPIRY + 10 ) , "with the commit" ) ;
rejects ( ( ) => takeover ( EXPIRY + 10 , { box : { ... boxUtxo ( ) , lockingBytecode : taker . lock } } ) , "input 1 is a plain coin, not a commit box" ) ;
rejects ( ( ) => takeover ( EXPIRY + 10 , { box : boxUtxo ( taker , "other.x" ) } ) , "a commit made for another name" ) ;
2026-10-04 16:53:56 +02:00
rejects ( ( ) => takeover ( EXPIRY + 10 , { box : boxUtxo ( taker , NAME , hexToBin ( "ff" . repeat ( 32 ) ) ) } ) , "a commit made with another salt" ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
rejects ( ( ) => takeover ( EXPIRY + 10 , { box : boxUtxo ( stranger ) } ) , "someone else's commit" ) ;
rejects ( ( ) => takeover ( EXPIRY + 10 , { tamper : ( tx ) => ( { ... tx , inputs : tx . inputs . map ( ( inp , i ) => ( i === 1 ? { ... inp , sequenceNumber : 0 } : inp ) ) } ) } ) , "a commit spent in the block it was made in" ) ;
rejects ( ( ) => takeover ( EXPIRY + 10 , { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : registryLock ( terms , { ownerPkh : stranger . pkh , expiry : EXPIRY + 10 + BLOCKS _PER _YEAR } ) } ) ) } ) , "the name goes to a key the commit was not made for" ) ;
} ) ;
test ( "takeover keeps the certificate in the covenant" , ( ) => {
rejects ( ( ) => takeover ( IDLE _END , { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : taker . lock } ) ) } ) , "certificate sent to the taker's wallet" ) ;
rejects ( ( ) => takeover ( IDLE _END , { tamper : setOut ( 0 , ( ) => ( { token : undefined } ) ) } ) , "certificate dropped" ) ;
} ) ;
// ---------- release ----------
const release = ( o = { } ) => buildRegistryReleaseTx ( { cov : covUtxo ( ) , terms , state : state0 , coins : [ coin ( 2 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) , ... o } ) ;
test ( "release destroys the certificate; it cannot be used to take it out" , ( ) => {
const built = release ( ) ;
accepts ( built , "release" ) ;
assert . ok ( built . transaction . outputs . every ( ( o ) => ! o . token ) ) ;
accepts ( release ( { extra : [ { lockingBytecode : Uint8Array . of ( 0x6a , 0x01 , 0x00 ) , valueSatoshis : 0 n } , { lockingBytecode : fund . lock , valueSatoshis : 600 n } ] } ) , "release with a REL payload and beacon dust" ) ;
for ( const i of [ 0 , 1 , 2 ] ) {
rejects ( ( ) => release ( { extra : [ { lockingBytecode : Uint8Array . of ( 0x6a , 0x01 , 0x00 ) , valueSatoshis : 0 n } , { lockingBytecode : fund . lock , valueSatoshis : 600 n } ] , tamper : setOut ( i , ( ) => ( { lockingBytecode : owner . lock , valueSatoshis : 1000 n , token : { category : hexToBin ( CATEGORY ) , amount : 0 n , nft : { capability : "none" , commitment : new TextEncoder ( ) . encode ( NAME ) } } } ) ) } ) , ` certificate recreated in output ${ i } ` ) ;
}
rejects ( ( ) => release ( { tamper : ( tx ) => ( { ... tx , outputs : [ ... tx . outputs . slice ( 0 , 1 ) , ... Array ( 4 ) . fill ( { lockingBytecode : owner . lock , valueSatoshis : 546 n } ) ] } ) } ) , "five outputs: one would be unchecked" ) ;
rejects ( ( ) => buildRegistryReleaseTx ( { cov : covUtxo ( ) , terms , state : state0 , coins : [ coin ( 2 , stranger ) ] , changeLock : stranger . lock , keyFor : ( ) => ( { privateKey : stranger . priv , publicKey : stranger . pub } ) } ) , "a stranger releases someone's name" ) ;
} ) ;
// ---------- migrate ----------
2026-10-04 16:53:56 +02:00
// A stand-in for a newer version's CODE. It runs behind the 26 state bytes
// (two pushes), which it drops; then it is spendable by the owner's key.
const NEW _CODE = flattenBinArray ( [ Uint8Array . of ( 0x6d ) , Uint8Array . of ( 0x21 ) , owner . pub , Uint8Array . of ( 0xac ) ] ) ; // OP_2DROP <pub> OP_CHECKSIG
const approval = signMigrationApproval ( steward . priv , terms , NEW _CODE ) ;
const migrate = ( o = { } ) => buildRegistryMigrateTx ( { cov : covUtxo ( ) , terms , state : state0 , newCode : NEW _CODE , approval , coins : [ coin ( 2 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) , ... o } ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
2026-10-04 16:53:56 +02:00
test ( "migrate: owner AND steward — neither can move a name alone, and the state carries over" , ( ) => {
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
const built = migrate ( ) ;
accepts ( built , "migrate" ) ;
2026-10-04 16:53:56 +02:00
assert . equal ( outLockHex ( built , 0 ) , binToHex ( migratedLock ( state0 , NEW _CODE ) ) , "same owner, same expiry, in front of the approved code" ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
2026-10-04 16:53:56 +02:00
rejects ( ( ) => migrate ( { approval : signMigrationApproval ( stranger . priv , terms , NEW _CODE ) } ) , "code approved by someone who is not the steward" ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
rejects ( ( ) => migrate ( { newCode : flattenBinArray ( [ NEW _CODE , Uint8Array . of ( 0x00 ) ] ) } ) , "code the steward did not sign" ) ;
2026-10-04 16:53:56 +02:00
rejects ( ( ) => migrate ( { approval : signMigrationApproval ( steward . priv , terms , Uint8Array . of ( 0x51 ) ) } ) , "an approval for different code" ) ;
rejects ( ( ) => buildRegistryMigrateTx ( { cov : covUtxo ( ) , terms , state : state0 , newCode : NEW _CODE , approval , coins : [ coin ( 2 , stranger ) ] , changeLock : stranger . lock , keyFor : ( ) => ( { privateKey : stranger . priv , publicKey : stranger . pub } ) } ) , "the steward's approval without the owner" ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
rejects ( ( ) => migrate ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : owner . lock } ) ) } ) , "certificate sent to a plain address under cover of a migration" ) ;
rejects ( ( ) => migrate ( { tamper : setOut ( 0 , ( ) => ( { token : undefined } ) ) } ) , "certificate dropped" ) ;
} ) ;
2026-10-04 16:53:56 +02:00
test ( "migrate: the owner chooses nothing — no script in front of the code, no new expiry, no new owner" , ( ) => {
// The first draft let the owner supply the bytes placed in front of the
// approved code. An owner could put their own signature check there and a
// push opcode that swallowed the approved code as data: the certificate left
// the covenant under any approval the steward had ever made. The contract
// now rebuilds the state itself, so every one of these targets is refused.
const push = ( b ) => ( b . length <= 75 ? flattenBinArray ( [ Uint8Array . of ( b . length ) , b ] ) : flattenBinArray ( [ Uint8Array . of ( 0x4c , b . length ) , b ] ) ) ;
const swallowed = flattenBinArray ( [ Uint8Array . of ( 0x21 ) , owner . pub , Uint8Array . of ( 0xad ) , push ( NEW _CODE ) ] ) ; // <pub> OP_CHECKSIGVERIFY PUSH(approved code)
rejects ( ( ) => migrate ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : p2sh32Lock ( swallowed ) } ) ) } ) , "the approved code wrapped as data behind the owner's own signature check" ) ;
rejects ( ( ) => migrate ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : migratedLock ( { ownerPkh : owner . pkh , expiry : 0x7ffffffe } , NEW _CODE ) } ) ) } ) , "a migration that also grants itself forty thousand years" ) ;
rejects ( ( ) => migrate ( { tamper : setOut ( 0 , ( ) => ( { lockingBytecode : migratedLock ( { ownerPkh : buyer . pkh , expiry : EXPIRY } , NEW _CODE ) } ) ) } ) , "a migration that also changes the owner" ) ;
} ) ;
test ( "migrate: an approval is for one covenant and is worthless in another" , ( ) => {
// Same steward, same new code — but the approval was made for tier 4's covenant.
const otherTerms = registryTerms ( { tier : 4 , fundLock : fund . lock , stewardPk : steward . pub } ) ;
rejects ( ( ) => migrate ( { approval : signMigrationApproval ( steward . priv , otherTerms , NEW _CODE ) } ) , "an approval made for another tier's covenant" ) ;
const otherFund = registryTerms ( { tier : TIER , fundLock : stranger . lock , stewardPk : steward . pub } ) ;
rejects ( ( ) => migrate ( { approval : signMigrationApproval ( steward . priv , otherFund , NEW _CODE ) } ) , "an approval made for a covenant with another fund (another network's, say)" ) ;
} ) ;
// ---------- bounds the review asked for ----------
test ( "renew cannot push the expiry past the range of block heights" , ( ) => {
const nearEnd = { ownerPkh : owner . pkh , expiry : 500_000_000 - BLOCKS _PER _YEAR } ;
const cov = covUtxo ( nearEnd ) ;
// one more year would be exactly 500,000,000: read as a timestamp, never takeable again
rejects ( ( ) => buildRegistryRenewTx ( { cov , terms , state : nearEnd , years : 1 , coins : [ coin ( 2 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) } ) , "expiry of exactly 500,000,000" ) ;
const justUnder = { ownerPkh : owner . pkh , expiry : 500_000_000 - BLOCKS _PER _YEAR - 1 } ;
accepts ( buildRegistryRenewTx ( { cov : covUtxo ( justUnder ) , terms , state : justUnder , years : 1 , coins : [ coin ( 2 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) } ) , "expiry of 499,999,999" ) ;
} ) ;
test ( "a commit is for one name: the salt has a fixed size, so nothing can slide between name and salt" , ( ) => {
assert . equal ( COMMIT _SALT _BYTES , 32 ) ;
assert . throws ( ( ) => commitHashFor ( NAME , hexToBin ( "00" . repeat ( 16 ) ) ) , /exactly 32 bytes/ ) ;
// The ambiguity, with real bytes: hash256("covenant" ‖ ".x" ‖ S) is one hash,
// whether you call the name "covenant" with salt ".x"‖S or "covenant.x" with salt S.
// Build a takeover of "covenant.x" from a box made as if for a 34-byte salt on a shorter name,
// by hand (the library refuses to), and confirm the CONTRACT refuses the odd-sized salt.
const longSalt = flattenBinArray ( [ new TextEncoder ( ) . encode ( "xx" ) , SALT ] ) ; // 34 bytes
const { hash256 } = L ;
const commitHash = hash256 ( flattenBinArray ( [ new TextEncoder ( ) . encode ( NAME ) , longSalt ] ) ) ;
const boxRedeem = commitBoxRedeem ( { ownerPkh : taker . pkh , commitHash } ) ;
const box = { txid : fakeTxid ( 7 ) , vout : 0 , satoshis : 1000 n , lockingBytecode : p2sh32Lock ( boxRedeem ) } ;
const height = IDLE _END , quote = takeoverQuote ( terms , state0 , height ) ;
const redeem = registryRedeem ( terms , state0 ) ;
const FN = selectorsOf ( NAME _REGISTRY ) ;
const takerKey = { privateKey : taker . priv , publicKey : taker . pub } ;
const cov = covUtxo ( ) ;
rejects ( ( ) => assemble ( {
special : [
{ utxo : cov , unlock : ( ) => unlockScript ( { selector : FN . takeover , redeem , args : [ hexToBin ( taker . pkh ) , longSalt ] } ) } ,
{ utxo : box , sequence : 1 , unlock : ( transaction , sourceOutputs , inputIndex , final ) => unlockScript ( { redeem : boxRedeem , transaction , sourceOutputs , inputIndex , key : final ? takerKey : null , args : [ PUBKEY , SIG ] } ) } ,
] ,
outputs : [
{ lockingBytecode : registryLock ( terms , { ownerPkh : taker . pkh , expiry : quote . newExpiry } ) , valueSatoshis : 1000 n , token : { amount : 0 n , category : hexToBin ( CATEGORY ) , nft : { capability : "none" , commitment : new TextEncoder ( ) . encode ( NAME ) } } } ,
{ lockingBytecode : fund . lock , valueSatoshis : quote . fundSats } ,
] ,
coins : [ coin ( 3 , taker , 50_000_000_000 n ) ] , changeLock : taker . lock , keyFor : keyring ( taker ) , locktime : height ,
} ) , "a takeover with a 34-byte salt, everything else in order" ) ;
} ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
// ---------- a life ----------
test ( "a name's life: renew, sell, lapse, be taken over, be renewed by its new owner" , ( ) => {
let state = state0 ;
let cov = covUtxo ( state ) ;
const step = ( built , next ) => { accepts ( built , "life" ) ; cov = { txid : built . txid , vout : 0 , satoshis : built . transaction . outputs [ 0 ] . valueSatoshis , lockingBytecode : built . transaction . outputs [ 0 ] . lockingBytecode , token : certToken ( ) } ; state = next ; } ;
let b = buildRegistryRenewTx ( { cov , terms , state , years : 2 , coins : [ coin ( 20 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) } ) ;
step ( b , b . state ) ;
b = buildRegistryTransferTx ( { cov , terms , state , newOwnerPkh : buyer . pkh , coins : [ coin ( 21 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner ) } ) ;
step ( b , b . state ) ;
assert . equal ( state . expiry , EXPIRY + 2 * BLOCKS _PER _YEAR ) ;
rejects ( ( ) => buildRegistryRenewTx ( { cov , terms , state , coins : [ coin ( 22 , owner ) ] , changeLock : owner . lock , keyFor : keyring ( owner , buyer ) === null ? null : ( h ) => ( h === owner . lockHex ? { privateKey : owner . priv , publicKey : owner . pub } : h === buyer . lockHex ? { privateKey : owner . priv , publicKey : owner . pub } : null ) } ) , "the old owner cannot renew a name they sold" ) ;
const height = state . expiry + 100 ; // the buyer lets it lapse
b = buildRegistryTakeoverTx ( { cov , terms , state , name : NAME , height , newOwnerPkh : taker . pkh , salt : SALT , box : boxUtxo ( ) , coins : [ coin ( 23 , taker , 50_000_000_000 n ) ] , changeLock : taker . lock , keyFor : keyring ( taker ) } ) ;
assert . equal ( binToHex ( b . transaction . outputs [ 2 ] . lockingBytecode ) , buyer . lockHex , "the buy-out goes to whoever owned it when it lapsed" ) ;
step ( b , b . state ) ;
assert . equal ( state . ownerPkh , taker . pkh ) ;
b = buildRegistryRenewTx ( { cov , terms , state , coins : [ coin ( 24 , taker ) ] , changeLock : taker . lock , keyFor : keyring ( taker ) } ) ;
step ( b , b . state ) ;
assert . equal ( state . expiry , height + 2 * BLOCKS _PER _YEAR ) ;
} ) ;
// ---------- the negatives are real ----------
test ( "every negative case was refused by the contract itself, not by the library" , ( ) => {
assert . deepEqual ( refusedByLibrary . sort ( ) , [ "zero years" ] , "only these may be stopped before the VM runs" ) ;
2026-10-04 16:53:56 +02:00
assert . ok ( refusedByVm >= 54 , ` expected the VM to refuse at least 54 tampered transactions, it refused ${ refusedByVm } ` ) ;
registry covenant v1: the contract that holds a name and enforces its lifecycle (draft)
Expiry was always going to be the chain refusing, not resolvers agreeing. This
is that contract: a certificate sits in a covenant whose address encodes its
owner and expiry, and five paths move it. The owner renews, transfers,
releases or migrates; anyone takes over an expired name at a price that
glides from a premium to a floor, paying the previous owner a share while the
grace period lasts and committing to the name a block ahead.
The two changing values are the last constructor arguments, so they sit first
in the redeem script and the contract re-creates itself by swapping 26 bytes.
A resolver that knows the code can check any claimed owner and expiry against
the address.
Every number comes from registry-lifecycle.js, which was written as the
fixture for this. The tests run each path in libauth's VM under the 2025 and
2026 rules, compare price and split to that model along the glide, and then
break one rule at a time with valid signatures; the suite counts that the VM
itself refused them.
Draft, not reviewed, and not wired into any resolver. REGISTRY-COVENANT-v1.md
says where it departs from the design: script cannot bound a height from
above, so reclaim has no deadline and a commit cannot expire, which leaves
commit hoarding open. That is the first thing a reviewer should read.
2026-10-04 15:42:36 +02:00
} ) ;