From 1f783e2929bc534bad004a4707dfacdf02c114c0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 7 Sep 2026 20:35:38 -0700 Subject: [PATCH 1/2] fix(encoder): floor every authored Dogecoin output at the 0.01 DOGE soft-dust limit Dogecoin Core keeps a soft dust limit of 0.01 DOGE above the pinned 0.001 hard limit: an output under it is relayed only if the whole limit is added to the fee, per output. The encoder sized P2SH funding legs off the hard limit, so once a node's estimatesmartfee answered around 0.011 DOGE/kB the legs came out at 0.003-0.009 DOGE, the funding parent owed about 0.04 DOGE of relay fee and paid 0.004, every default-policy peer dropped it (the submitting node keeps it, since sendrawtransaction skips that check), and every PRICE and ATTEST wire built that way sat unconfirmed behind its parent. - SOFT_DUST_FLOOR_BY_COIN (DOGE 1000000 koinu) and a separate outputFloor for every output the encoder authors: funding legs, the bare-multisig data output, change emission, the reveal's change headroom and surplus sweep. dustAmount stays the pinned consensus constant for the fee floor, the fee-drain caps and the burn guard, so no transaction gains a 0.01 DOGE minimum fee. - A caller dust may raise the floor, never lower it. - DUST_AMOUNT is read in api.js and passed to the constructor, as the docs have promised; it only raises the floor. - Tests pin the floors per coin, the operator override, and P2SH builds at the observed stall rate authoring no leg under 0.01 DOGE; three suites re-pinned from the hard-dust arithmetic to the output floor. --- .env.example | 6 + src/XChainEncoder.js | 47 ++++-- src/api.js | 6 +- test/e2e/edge-cases.e2e.js | 4 +- test/e2e/encoding-boundaries.e2e.js | 2 +- test/e2e/obfuscation-integrity.e2e.js | 2 +- test/e2e/round-trip.e2e.js | 2 +- test/regression/reg-01-encoding-types.test.js | 10 +- .../XChainEncoder.createTransaction.test.js | 36 +++- .../XChainEncoder.p2shRevealHeadroom.test.js | 25 +-- test/unit/XChainEncoder.softDustFloor.test.js | 159 ++++++++++++++++++ 11 files changed, 264 insertions(+), 35 deletions(-) create mode 100644 test/unit/XChainEncoder.softDustFloor.test.js diff --git a/.env.example b/.env.example index 0d36402..11b6f2a 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,12 @@ UTXO_TRACKER_API_PORT=3001 # raises an error rather than a guess. # FEE_NO_ESTIMATE_RELAY_MULTIPLIER=10 +# Optional: floor (base units) on every value output the encoder authors. +# It can only raise the floor. The coin's consensus dust threshold and its +# relay-policy soft-dust floor apply regardless (Dogecoin: 0.01 DOGE, below +# which each output adds the whole limit to the fee the network demands). +# DUST_AMOUNT= + # Optional: API hardening for shared deployments. # API_KEY= # ENCODER_RATE_LIMIT_RPM=60 diff --git a/src/XChainEncoder.js b/src/XChainEncoder.js index 7861070..6989dd1 100644 --- a/src/XChainEncoder.js +++ b/src/XChainEncoder.js @@ -62,6 +62,20 @@ const SATOSHI_UNIT = 100000000 const MAX_SAFE_SATOSHI_BIG = BigInt(Number.MAX_SAFE_INTEGER) +// Relay-policy floor per coin for every value output this encoder authors, distinct +// from the consensus-pinned network.dustThreshold the decoder reads. Dogecoin relays an +// output under its 0.01 DOGE soft dust limit only if the whole limit is added to the fee. +const SOFT_DUST_FLOOR_BY_COIN = { + DOGE: 1000000 +} + +// Keyed on the coin, not the network: the soft limit is node policy on every Dogecoin chain. +function softDustFloorFor(networkKey){ + const fullName = String(networkKey || '').slice(0, Math.max(0, String(networkKey || '').lastIndexOf('-'))) + const tick = require('./coins').FULL_NAME_TO_TICK[fullName] + return (tick && SOFT_DUST_FLOOR_BY_COIN[tick]) || 0 +} + // Byte width of the compactSize varint that prefixes a length on the wire. // Distinct from compiledPushSize: that models bitcoin.script.compile's PUSH // OPCODE framing (direct push / OP_PUSHDATA1 / OP_PUSHDATA2) and is correct @@ -347,7 +361,7 @@ function assertRevealFundingTxMatches(p2shHash, fundingTxid){ class XChainEncoder { - constructor(network, nodeUrl, nodePort, nodeUser, nodePassword, utxoTrackerUrl, utxoTrackerPort, maxFeeRateKb=null, maxFeeRateMultiplier=DEFAULT_MAX_FEE_RATE_MULTIPLIER, maxUtxoTrackerLagBlocks=DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS) { + constructor(network, nodeUrl, nodePort, nodeUser, nodePassword, utxoTrackerUrl, utxoTrackerPort, maxFeeRateKb=null, maxFeeRateMultiplier=DEFAULT_MAX_FEE_RATE_MULTIPLIER, maxUtxoTrackerLagBlocks=DEFAULT_MAX_UTXO_TRACKER_LAG_BLOCKS, dustAmount=null) { this.network = CryptoNetworks.getBitcoinJsNetwork(network) // The raw "-" key. getBitcoinJsNetwork returns only the // bitcoinjs params, which carry no chain identity, and the envelope @@ -369,7 +383,16 @@ class XChainEncoder { require('./coins').verifyConsensusPin(this.consensusNetwork) this.connector = new BlockchainConnector(nodeUrl, nodePort, nodeUser, nodePassword) this.utxoTrackerConnector = new UtxoTracker(utxoTrackerUrl, utxoTrackerPort) + // Two floors: dustAmount is the pinned consensus threshold (fee floor, fee-drain + // caps, burn guard); outputFloor bounds every output this encoder authors and is + // the same threshold raised to the coin relay floor and again to an operator DUST_AMOUNT. this.dustAmount = this.network["dustThreshold"] + const operatorDust = Number(dustAmount) + this.outputFloor = Math.max( + this.dustAmount, + softDustFloorFor(network), + (Number.isFinite(operatorDust) && operatorDust > 0) ? Math.floor(operatorDust) : 0 + ) // Maximum fee rate in BTC/byte (null = no cap). Prevents runaway estimates // (e.g. regtest feedback loop) from producing fees that the node will reject. // MAX_FEE_RATE_KB is in sat/kB, convert to BTC/byte to match feePerBytes units. @@ -985,9 +1008,11 @@ class XChainEncoder { feePerBytes = capFeePerBytes } - let finalDust = this.dustAmount + // A caller dust may raise the floor for this build, never lower it: a leg under the + // relay floor strands the caller's own reveal behind an unrelayable funding tx. + let finalDust = this.outputFloor if (dust){ - finalDust = dust + finalDust = Math.max(Number(dust), this.outputFloor) } // Transparent FILE payload compression, ON by default. Runs HERE, before @@ -1694,7 +1719,8 @@ class XChainEncoder { revealFeeNeeded = this.dustAmount } let revealShortfall = Math.max(0, revealFeeNeeded - baseLegsTotal) - let changeHeadroom = needsChangeOutput ? BigInt(this.dustAmount) : 0n + // The reveal's change is an authored output, so its headroom is the relay floor. + let changeHeadroom = needsChangeOutput ? BigInt(this.outputFloor) : 0n spendingP2shEstimatedFee = asSatValue(BigInt(spendingP2shEstimatedFee) + BigInt(revealShortfall) + changeHeadroom) } @@ -2321,12 +2347,10 @@ class XChainEncoder { } if ((changeSatoshis > 0) && (change)) { - // Only emit change at or above the per-coin dust threshold - // (this.dustAmount, the same constant the burn guard above keys on). - // Change of 1..dust-1 sats is an unspendable, non-standard output - // that makes the whole transaction unbroadcastable, so fold it into - // the miner fee (leave it unclaimed) rather than emit it. - if (changeSatoshis >= this.dustAmount) { + // Emit change only at or above the relay floor for authored outputs; below it the + // output is non-standard or soft dust that makes the transaction unrelayable, so it + // folds into the miner fee instead. + if (changeSatoshis >= this.outputFloor) { psbt.addOutput({ address: change, value: asSatValue(changeSatoshis) @@ -2389,7 +2413,8 @@ class XChainEncoder { } let revealSurplus = phaseLegInputSatoshis - outputSatoshis - BigInt(revealFeeKept) let sweepAddress = change || resolveCallerAddress(pubkey, this.network) - if (sweepAddress && revealSurplus >= BigInt(this.dustAmount)){ + // The sweep is an authored output: below the relay floor the surplus stays as fee. + if (sweepAddress && revealSurplus >= BigInt(this.outputFloor)){ psbt.addOutput({ address: sweepAddress, value: asSatValue(revealSurplus) diff --git a/src/api.js b/src/api.js index 5846c2f..27f11b9 100644 --- a/src/api.js +++ b/src/api.js @@ -89,6 +89,10 @@ const MAX_FEE_RATE_MULTIPLIER = Number.isFinite(_maxFeeRateMultiplier) ? _maxFee // pattern as MAX_FEE_RATE_MULTIPLIER above. const _utxoTrackerMaxLagBlocks = parseInt(process.env.UTXO_TRACKER_MAX_LAG_BLOCKS, 10) const UTXO_TRACKER_MAX_LAG_BLOCKS = Number.isFinite(_utxoTrackerMaxLagBlocks) ? _utxoTrackerMaxLagBlocks : undefined +// Operator floor on every value output the encoder authors; it only raises the floor +// above the coin dust threshold and relay-policy soft-dust floor (see XChainEncoder). +const _dustAmount = parseInt(process.env.DUST_AMOUNT, 10) +const DUST_AMOUNT = (Number.isFinite(_dustAmount) && _dustAmount > 0) ? _dustAmount : undefined const API_KEY = process.env.API_KEY const CORS_ORIGIN = process.env.CORS_ORIGIN @@ -108,7 +112,7 @@ if (!API_KEY) { console.warn('NOTICE: API_KEY not set. Encoder API authentication is DISABLED (open access).') } -const encoder = new XChainEncoder(NETWORK, NODE_URL, NODE_PORT, NODE_USER, NODE_PASSWORD, UTXO_TRACKER_URL, UTXO_TRACKER_API_PORT, MAX_FEE_RATE_KB, MAX_FEE_RATE_MULTIPLIER, UTXO_TRACKER_MAX_LAG_BLOCKS); +const encoder = new XChainEncoder(NETWORK, NODE_URL, NODE_PORT, NODE_USER, NODE_PASSWORD, UTXO_TRACKER_URL, UTXO_TRACKER_API_PORT, MAX_FEE_RATE_KB, MAX_FEE_RATE_MULTIPLIER, UTXO_TRACKER_MAX_LAG_BLOCKS, DUST_AMOUNT); const app = express(); diff --git a/test/e2e/edge-cases.e2e.js b/test/e2e/edge-cases.e2e.js index da26673..ccde47d 100644 --- a/test/e2e/edge-cases.e2e.js +++ b/test/e2e/edge-cases.e2e.js @@ -258,7 +258,9 @@ describe('E2E-7: Complex Parameter & Edge Case Handling', () => { const encoder = makeEncoder(NETWORK) const address = getTestAddress(NETWORK) const utxo = makeUtxo(NETWORK, TXID_MULTISIGN, 0, 100000000) - const customDust = 1234 + // Above the DOGE output floor (0.01 DOGE), so the caller value is honoured; + // a caller dust below the floor is clamped up to it. + const customDust = 1500000 const result = await encoder.createTransaction( [utxo], address, null, diff --git a/test/e2e/encoding-boundaries.e2e.js b/test/e2e/encoding-boundaries.e2e.js index 961bb70..615c4f5 100644 --- a/test/e2e/encoding-boundaries.e2e.js +++ b/test/e2e/encoding-boundaries.e2e.js @@ -151,7 +151,7 @@ describe('E2E-3: Encoding Type Selection & Boundaries', () => { assert.strictEqual(result.encoding, 'MULTISIGN') const encoder = makeEncoder(NETWORK) - const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.dustAmount) + const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.outputFloor) assert.ok(msOutput, 'should have multisig output at dust value') const decompiled = bitcoin.script.decompile(msOutput.script) diff --git a/test/e2e/obfuscation-integrity.e2e.js b/test/e2e/obfuscation-integrity.e2e.js index 8fedad1..14be37a 100644 --- a/test/e2e/obfuscation-integrity.e2e.js +++ b/test/e2e/obfuscation-integrity.e2e.js @@ -89,7 +89,7 @@ describe('E2E-4: Obfuscation Integrity', () => { null, null, PUBKEY_BUF.toString('hex'), true, 0.00001 ) - const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.dustAmount) + const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.outputFloor) assert.strictEqual(payload.magic, MAGIC_WORD) const decompiled = decompilePayload(payload.dataBuffer) diff --git a/test/e2e/round-trip.e2e.js b/test/e2e/round-trip.e2e.js index 78b45c8..45ecc75 100644 --- a/test/e2e/round-trip.e2e.js +++ b/test/e2e/round-trip.e2e.js @@ -142,7 +142,7 @@ describe('E2E-9: Round-Trip Verification', () => { assert.strictEqual(result.encoding, 'MULTISIGN') - const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.dustAmount) + const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.outputFloor) assert.strictEqual(payload.magic, MAGIC_WORD) const decompiled = decompilePayload(payload.dataBuffer) diff --git a/test/regression/reg-01-encoding-types.test.js b/test/regression/reg-01-encoding-types.test.js index 4473712..22ec916 100644 --- a/test/regression/reg-01-encoding-types.test.js +++ b/test/regression/reg-01-encoding-types.test.js @@ -254,8 +254,10 @@ describe('REG-01: Core Encoding Types', function () { assert.strictEqual(result.encoding, 'MULTISIGN') - const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.dustAmount) - assert.ok(msOutput, 'should have a multisig output at dust value') + // The data output is an authored output: it sits at the relay floor (outputFloor, + // the 0.01 DOGE soft-dust limit on Dogecoin), not at the pinned hard-dust fee floor. + const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.outputFloor) + assert.ok(msOutput, 'should have a multisig output at the output floor') const d = bitcoin.script.decompile(msOutput.script) // OP_1 OP_3 OP_CHECKMULTISIG @@ -276,7 +278,7 @@ describe('REG-01: Core Encoding Types', function () { null, null, COMPRESSED_PUBKEY, true, 0.00001 ) - const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.dustAmount) + const msOutput = result.psbt.txOutputs.find(o => o.value === encoder.outputFloor) const d = bitcoin.script.decompile(msOutput.script) assert.strictEqual(d[3].toString('hex'), COMPRESSED_PUBKEY) }) @@ -293,7 +295,7 @@ describe('REG-01: Core Encoding Types', function () { null, null, COMPRESSED_PUBKEY, true, 0.00001 ) - const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.dustAmount) + const payload = extractMultisignPayload(result, TXID_MULTISIGN, encoder.outputFloor) assert.strictEqual(payload.magic, MAGIC_WORD) }) }) diff --git a/test/unit/XChainEncoder.createTransaction.test.js b/test/unit/XChainEncoder.createTransaction.test.js index 8ca97b7..53703f3 100644 --- a/test/unit/XChainEncoder.createTransaction.test.js +++ b/test/unit/XChainEncoder.createTransaction.test.js @@ -378,11 +378,13 @@ describe('XChainEncoder.createTransaction()', () => { }) describe('custom dust parameter', () => { - it('overrides finalDust for MULTISIGN output value', async () => { + it('overrides finalDust for MULTISIGN output value when it raises the floor', async () => { const encoder = makeEncoder() const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) const compressedPubKey = pubkeyBuf.toString('hex') - const customDust = 1234 + // Above the LTC output floor (5460), so the caller's value is honoured. + const customDust = 6000 + assert.ok(customDust > encoder.outputFloor) const MS_DATA = 'A'.repeat(59) @@ -394,7 +396,28 @@ describe('XChainEncoder.createTransaction()', () => { ) const msOutput = result.psbt.txOutputs.find(o => o.value === customDust) - assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 1234') + assert.ok(msOutput, 'MULTISIGN output should use custom dust value of 6000') + }) + + it('clamps a caller dust below the output floor up to the floor', async () => { + // 1234 litoshi is under LTC's 5460 dust threshold: an output that size is + // non-standard and the whole transaction unrelayable, so a caller may + // raise the floor but never lower it. + const encoder = makeEncoder() + const utxo = makeSegwitUtxo(TXID_MS, 0, 100000000) + const compressedPubKey = pubkeyBuf.toString('hex') + const customDust = 1234 + assert.ok(customDust < encoder.outputFloor) + + const result = await encoder.createTransaction( + [utxo], TEST_ADDRESS, null, + 'A'.repeat(59), null, 10000, false, 'MULTISIGN', TEST_ADDRESS, + null, null, compressedPubKey, + true, 0.00001, customDust + ) + + assert.ok(!result.psbt.txOutputs.find(o => o.value === customDust), 'no output at the sub-floor value') + assert.ok(result.psbt.txOutputs.find(o => o.value === encoder.outputFloor), 'the data output sits at the floor') }) it('does NOT override the fee floor (fee floor uses this.dustAmount)', async () => { @@ -766,7 +789,10 @@ describe('XChainEncoder.createTransaction()', () => { it('phase 1 (funding) over-funds the P2SH output by the fee value plus its byte cost', async () => { const encoder = makeEncoder() + // Pin both floors: the reveal's change headroom keys on outputFloor, the fee + // floor on dustAmount, and the delta below assumes one value for each. encoder.dustAmount = 546 + encoder.outputFloor = 546 const utxo = makeSegwitUtxo(TXID_A, 0, 100000000) const TxSizeEstimator = require('../../src/TxSizeEstimator') @@ -805,9 +831,9 @@ describe('XChainEncoder.createTransaction()', () => { const feeOutputBytes = TxSizeEstimator.estimateOutputSizeForAddress(TEST_ADDRESS, LTC_REGTEST) const revealByteFee = Math.ceil(feeOutputBytes * feePerBytes * 1e8) - assert.strictEqual(feeFunding - baseFunding, FEE_VALUE + revealByteFee - encoder.dustAmount, + assert.strictEqual(feeFunding - baseFunding, FEE_VALUE + revealByteFee - encoder.outputFloor, 'funding output must grow by the fee value plus the fee output byte cost, ' + - 'less the change dust only the outputless baseline needs') + 'less the change headroom (one output floor) only the outputless baseline needs') }) it('phase 2 (reveal) emits the fee-destination output funded by phase 1', async () => { diff --git a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js index e52f199..6b80720 100644 --- a/test/unit/XChainEncoder.p2shRevealHeadroom.test.js +++ b/test/unit/XChainEncoder.p2shRevealHeadroom.test.js @@ -133,6 +133,8 @@ function legOutputs (tx) { const DOGE = 'dogecoin-regtest' const DOGE_DUST = 100000 +// Dogecoin's soft dust limit (0.01 DOGE): the floor on every output the encoder authors on DOGE. +const DOGE_SOFT_DUST = 1000000 const VENUE_RATE_KB = 1000000 // 1000 koinu/byte, the venue recipe rate describe('XChainEncoder P2SH reveal headroom', () => { @@ -184,20 +186,20 @@ describe('XChainEncoder P2SH reveal headroom', () => { assert.ok(sweep.value >= DOGE_DUST, `sweep ${sweep && sweep.value} must be at least dust ${DOGE_DUST}`) }) - it('funds dust-dominant legs with exactly one extra dust of headroom (tiny fee rate)', async () => { + it('funds floor-dominant legs with exactly one extra output floor of headroom (tiny fee rate)', async () => { const encoder = makeEncoder(DOGE) - // 1 koinu/byte: every size-based estimate is far below the 100000 dust - // floor, so pre-fix the leg was exactly one dust. Now it is dust (leg - // floor) + dust (reveal change headroom); the reveal keeps one dust as - // its (dust-floored) fee and sweeps the other back. + // 1 koinu/byte: every size-based estimate is far below the output floor, so the + // leg is floor (leg value) + floor (reveal change headroom), the 0.01 DOGE soft + // limit on Dogecoin; the reveal fee still floors at the pinned hard dust. const funding = await buildFunding(encoder, DOGE, 'P2SH', 1000, 120) const legs = legOutputs(funding) assert.strictEqual(legs.length, 1) - assert.strictEqual(legs[0].value, 2 * DOGE_DUST) + assert.strictEqual(encoder.outputFloor, DOGE_SOFT_DUST) + assert.strictEqual(legs[0].value, 2 * DOGE_SOFT_DUST) const reveal = await buildReveal(encoder, DOGE, 'P2SH', 1000, 120, funding) const totalOut = reveal.outs.reduce((s, o) => s + o.value, 0) - assert.strictEqual(totalOut, DOGE_DUST, 'exactly one dust sweeps back; one dust stays as the reveal fee') + assert.strictEqual(totalOut, 2 * DOGE_SOFT_DUST - DOGE_DUST, 'one hard dust stays as the reveal fee; the rest sweeps back') }) it('scales the leg with the fee rate and with the payload size', async () => { @@ -206,9 +208,12 @@ describe('XChainEncoder P2SH reveal headroom', () => { const funding = await buildFunding(encoder, DOGE, 'P2SH', rateKb, payloadLen) return legOutputs(funding).reduce((s, o) => s + o.value, 0) } - const base = await legAt(VENUE_RATE_KB, 120) - const doubleRate = await legAt(2 * VENUE_RATE_KB, 120) - const biggerPayload = await legAt(VENUE_RATE_KB, 400) + // Probe above the output floor: at the venue rate a 120-byte chunk prices + // under the 0.01 DOGE soft-dust floor and the leg is floor-bound, so the + // scaling only shows once the per-chunk fee share exceeds the floor. + const base = await legAt(10 * VENUE_RATE_KB, 120) + const doubleRate = await legAt(20 * VENUE_RATE_KB, 120) + const biggerPayload = await legAt(10 * VENUE_RATE_KB, 400) assert.ok(doubleRate > base, `leg must grow with the fee rate (${doubleRate} vs ${base})`) assert.ok(biggerPayload > base, `leg must grow with the payload (${biggerPayload} vs ${base})`) }) diff --git a/test/unit/XChainEncoder.softDustFloor.test.js b/test/unit/XChainEncoder.softDustFloor.test.js new file mode 100644 index 0000000..390bdaf --- /dev/null +++ b/test/unit/XChainEncoder.softDustFloor.test.js @@ -0,0 +1,159 @@ +// Copyright © 2025–2026 Dankest, LLC +// Based on XChain Platform by Dankest, LLC – https://dankest.llc +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This file is part of XChain Platform. Licensed under the GNU Affero +// General Public License v3.0 or later; see LICENSE.md. A commercial +// license (without AGPL source-disclosure terms) is available - +// contact legal@dankest.llc. + +// Dogecoin relays an output under its 0.01 DOGE soft dust limit only if the whole +// limit is added to the fee, per output; a P2SH funding leg sized off the 0.001 hard +// limit therefore never reaches a miner. These tests pin the floor on authored outputs. + +const assert = require('assert') +const bitcoin = require('bitcoinjs-lib') +const XChainEncoder = require('../../src/XChainEncoder') +const CryptoNetworks = require('../../src/CryptoNetworks') + +const SOFT_DUST_DOGE = 1000000 // 0.01 DOGE in koinu +const HARD_DUST_DOGE = 100000 // the pinned network.dustThreshold + +const pubkeyBuf = Buffer.from( + '0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'hex' +) +const TXID_A = 'a'.repeat(64) + +function p2pkhFor (network) { + return bitcoin.payments.p2pkh({ pubkey: pubkeyBuf, network }) +} + +function makeLegacyUtxo (network, txid, vout, value) { + return { + txid, + vout, + value, + confirmations: 6, + scriptPubKey: p2pkhFor(network).output.toString('hex') + } +} + +function buildRawP2pkhTxHex (network, value) { + const tx = new bitcoin.Transaction() + tx.addInput(Buffer.alloc(32, 0x11), 0) + tx.addOutput(p2pkhFor(network).output, value) + return tx.toHex() +} + +// A DOGE regtest encoder whose node reports the fee rate measured live on +// testnet during the stall (0.0112 DOGE/kB), with one large confirmed input. +function makeDogeEncoder (feePerKb, dustAmount) { + const encoder = new XChainEncoder( + 'dogecoin-regtest', '127.0.0.1', '18332', 'rpc', 'rpc', '', '', + null, undefined, undefined, dustAmount + ) + const network = encoder.network + encoder.connector = { + getFeePerKilobyte: async () => feePerKb, + getTransactionHex: async () => buildRawP2pkhTxHex(network, 100000000000) + } + encoder.utxoTrackerConnector = { + getUtxosFromAddress: async () => ({ + utxos: [makeLegacyUtxo(network, TXID_A, 0, 100000000000)] + }) + } + return encoder +} + +// A PRICE-batch-sized payload: ~2 KB, which the P2SH path splits into several +// funding legs, the same shape as the stalled testnet wires. +const PRICE_SIZED_PAYLOAD = 'PRICE|' + 'x'.repeat(2000) +const LIVE_STALL_FEE_PER_KB = 0.0112 + +async function buildDogeFunding (encoder, dust) { + const network = encoder.network + const address = p2pkhFor(network).address + const utxo = makeLegacyUtxo(network, TXID_A, 0, 100000000000) + return encoder.createTransaction( + [utxo], address, null, + PRICE_SIZED_PAYLOAD, null, null, false, 'P2SH', address, + null, null, null, true, LIVE_STALL_FEE_PER_KB, dust, null, false, false + ) +} + +describe('XChainEncoder soft-dust floor on authored outputs', () => { + describe('the floor', () => { + it('raises the DOGE floor from the pinned hard limit to the 0.01 DOGE soft limit on every network', () => { + for (const key of ['dogecoin-regtest', 'dogecoin-testnet', 'dogecoin-mainnet']) { + const encoder = new XChainEncoder(key, '127.0.0.1', '18332', 'rpc', 'rpc', '', '') + assert.strictEqual(encoder.network.dustThreshold, HARD_DUST_DOGE, key + ': the pinned consensus constant is untouched') + assert.strictEqual(encoder.dustAmount, HARD_DUST_DOGE, key + ': the fee floor stays on the pinned constant') + assert.strictEqual(encoder.outputFloor, SOFT_DUST_DOGE, key + ': authored outputs floor at the soft limit') + } + }) + + it('leaves BTC and LTC at their pinned dust threshold (no soft limit there)', () => { + for (const key of ['bitcoin-regtest', 'litecoin-regtest']) { + const encoder = new XChainEncoder(key, '127.0.0.1', '18332', 'rpc', 'rpc', '', '') + assert.strictEqual(encoder.outputFloor, encoder.network.dustThreshold, key) + } + }) + + it('lets an operator DUST_AMOUNT raise the floor but never lower it', () => { + const raised = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '18332', 'rpc', 'rpc', '', '', null, undefined, undefined, 2000000) + assert.strictEqual(raised.outputFloor, 2000000) + assert.strictEqual(raised.dustAmount, HARD_DUST_DOGE, 'the fee floor never moves with DUST_AMOUNT') + const lowered = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '18332', 'rpc', 'rpc', '', '', null, undefined, undefined, 500000) + assert.strictEqual(lowered.outputFloor, SOFT_DUST_DOGE, 'below the soft limit is ignored') + const garbage = new XChainEncoder('dogecoin-regtest', '127.0.0.1', '18332', 'rpc', 'rpc', '', '', null, undefined, undefined, 'nope') + assert.strictEqual(garbage.outputFloor, SOFT_DUST_DOGE, 'unparseable is ignored') + const btc = new XChainEncoder('bitcoin-regtest', '127.0.0.1', '18332', 'rpc', 'rpc', '', '', null, undefined, undefined, 1000) + assert.strictEqual(btc.outputFloor, 1000, 'BTC takes the operator floor above 546') + }) + }) + + describe('P2SH funding legs at the fee rate that stalled testnet', () => { + it('is a discriminating rate: the per-chunk fee share alone sits under the soft limit', () => { + // Guards the test itself: at 0.0112 DOGE/kB a chunk-sized reveal input + // prices well under 0.01 DOGE, so only the floor can lift the legs. + const encoder = makeDogeEncoder(LIVE_STALL_FEE_PER_KB) + const chunk = Buffer.alloc(520, 0x78) + const chunkFee = Math.trunc(encoder.estimateSpendingP2shTx(chunk) * (LIVE_STALL_FEE_PER_KB / 1000) * 100000000) + assert.ok(chunkFee < SOFT_DUST_DOGE, 'chunk fee share ' + chunkFee + ' should be under the soft limit') + assert.ok(chunkFee > HARD_DUST_DOGE, 'and over the hard limit, exactly the stalled band') + }) + + it('authors no funding leg below 0.01 DOGE', async () => { + const encoder = makeDogeEncoder(LIVE_STALL_FEE_PER_KB) + const funding = await buildDogeFunding(encoder, null) + assert.strictEqual(funding.encoding, 'P2SH') + const outs = funding.psbt.txOutputs + assert.ok(outs.length >= 3, 'a PRICE-sized payload needs several legs, got ' + outs.length) + for (const o of outs) { + assert.ok(o.value >= SOFT_DUST_DOGE, 'output of ' + o.value + ' koinu is under the 0.01 DOGE soft limit') + } + }) + + it('clamps a caller dust below the floor up to the floor', async () => { + const encoder = makeDogeEncoder(LIVE_STALL_FEE_PER_KB) + const funding = await buildDogeFunding(encoder, HARD_DUST_DOGE) + for (const o of funding.psbt.txOutputs) { + assert.ok(o.value >= SOFT_DUST_DOGE, 'caller dust=' + HARD_DUST_DOGE + ' still authored a ' + o.value + ' koinu leg') + } + }) + + it('still lets a caller raise the floor above the soft limit', async () => { + const encoder = makeDogeEncoder(LIVE_STALL_FEE_PER_KB) + const funding = await buildDogeFunding(encoder, 3000000) + for (const o of funding.psbt.txOutputs) { + assert.ok(o.value >= 3000000, 'caller dust=3000000 authored a ' + o.value + ' koinu leg') + } + }) + }) +}) + +// Keep the module-level CryptoNetworks import meaningful for readers of the +// fixtures above: every address here is derived from the coin's own params. +assert.ok(CryptoNetworks.getBitcoinJsNetwork('dogecoin-regtest').pubKeyHash !== undefined) From dc96430888db8ad7c4c4a25b5cd377733385c22a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Mon, 7 Sep 2026 20:35:38 -0700 Subject: [PATCH 2/2] release: v0.15.4 --- CHANGELOG.md | 6 ++++++ README.md | 3 ++- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b7435..bf45761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.4] - 2026-09-08 + +### Fixed +- Every output the encoder authors on Dogecoin is floored at the 0.01 DOGE soft-dust limit, so P2SH funding legs relay at any fee rate. +- `DUST_AMOUNT` is honoured as an operator floor on authored outputs. + ## [0.15.0] - 2026-09-07 ### Added diff --git a/README.md b/README.md index d7e68b9..6b0c29e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Encoder

- Version + Version Tests Node License @@ -86,6 +86,7 @@ npm run api | `MAX_FEE_RATE_MULTIPLIER` | No | `100` | Caps caller-supplied fee/feePerKb at this multiple of the node's fee estimate (`0` disables) | | `MAX_CPFP_UPLIFT_SAT` | No | `10000000` | Most a transaction spending unconfirmed inputs may add to its fee so the whole mempool package reaches the target rate (`0` disables package-aware sizing) | | `FEE_NO_ESTIMATE_RELAY_MULTIPLIER` | No | `10` | Multiple of the node's relay floor charged on a non-mainnet chain when `estimatesmartfee` has no data. Raise it where miners ignore the documented rate (`100` gives 0.1 DOGE/kB). Mainnet is unaffected | +| `DUST_AMOUNT` | No | Coin default | Floor in base units on every value output the encoder authors (funding legs, data outputs, change). Only raises the floor: the coin's consensus dust threshold and its relay-policy soft-dust floor (Dogecoin: 0.01 DOGE, below which each output adds the whole limit to the required relay fee) already apply | | `XCHAIN_COMPRESSION_DEFAULT` | No | Enabled | Deployment default for transparent FILE compression; set `0`, `false`, or `off` to disable | | `ENCODER_REPLICAS` | No | `1` | Deploy-manifest declared replica count; boot refuses above `1` until the in-process reservation, recent-build and rate-limit stores are shared | | `API_KEY` | No | Disabled | API key for `x-api-key` header authentication | diff --git a/package-lock.json b/package-lock.json index 7f350a3..9d32a05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xchain-encoder", - "version": "0.15.0", + "version": "0.15.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xchain-encoder", - "version": "0.15.0", + "version": "0.15.4", "license": "AGPL-3.0-or-later", "dependencies": { "axios": "^1.18.1", diff --git a/package.json b/package.json index f4c4f41..f7d24b0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xchain-encoder", "description": "xchain-encoder encodes XChain Platform ACTION commands into blockchain transactions.", - "version": "0.15.0", + "version": "0.15.4", "license": "AGPL-3.0-or-later", "repository": { "type": "git",