Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# XChain Platform Encoder

<p align="center">
<img src="https://img.shields.io/badge/version-0.15.0-blue" alt="Version">
<img src="https://img.shields.io/badge/version-0.15.4-blue" alt="Version">
<img src="https://img.shields.io/badge/tests-1%2C604%2B%20passing-brightgreen" alt="Tests">
<img src="https://img.shields.io/badge/node-%3E%3D22-green" alt="Node">
<img src="https://img.shields.io/badge/license-AGPL--3.0--or--later-blue" alt="License">
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
47 changes: 36 additions & 11 deletions src/XChainEncoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 "<coin>-<net>" key. getBitcoinJsNetwork returns only the
// bitcoinjs params, which carry no chain identity, and the envelope
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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();

Expand Down
4 changes: 3 additions & 1 deletion test/e2e/edge-cases.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/encoding-boundaries.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/obfuscation-integrity.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/round-trip.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions test/regression/reg-01-encoding-types.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pk1> <pk2> <pk3> OP_3 OP_CHECKMULTISIG
Expand All @@ -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)
})
Expand All @@ -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)
})
})
Expand Down
36 changes: 31 additions & 5 deletions test/unit/XChainEncoder.createTransaction.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading