Rust-powered DLC (Discreet Log Contracts) bindings for JavaScript environments
This repository provides high-performance Rust bindings for dlcdevkit and rust-dlc, making DLC functionality available in:
- Node.js/TypeScript: @bennyblader/ddk-ts - generated N-API native bindings
- React Native: @bennyblader/ddk-rn - UniFFI-based native bindings with JSI
Neither package compiles anything on install β both ship prebuilt binaries.
@bennyblader/ddk-ts - Node.js/TypeScript
Native Node.js bindings for server-side applications, CLI tools, and desktop
apps, generated from the same ddk-ffi crate as the React Native package.
npm install @bennyblader/ddk-tsFeatures:
- Generated from
ddk-ffi, so the API matchesddk-rnby construction - Prebuilt binaries for macOS ARM64 and Linux x64 β nothing compiles on install
- Full TypeScript support
- Synchronous API for performance
- ESM-only
View package documentation β
@bennyblader/ddk-rn - React Native
React Native bindings using UniFFI for mobile DLC applications.
npm install @bennyblader/ddk-rnFeatures:
- JSI-based high-performance bridge
- iOS and Android support, shipped as a prebuilt XCFramework + JNI libraries
- Requires the React Native new architecture; built and E2E-tested against RN 0.80
- TurboModule optimizations
View package documentation β
Prereleases publish to the
nextdist-tag:npm install @bennyblader/ddk-rn@next.
The contract API runs the whole DLC lifecycle β offer, accept, fund, sign, settle, and splice β without a contract store.
Nothing is persisted. Every transaction is rebuilt from the OfferDlc /
AcceptDlc / SignDlc wire messages at the moment it is needed, so there is no
state to keep in sync: hold the three messages (and one 32-byte
temporaryContractId) and you can reconstruct and sign anything the contract can
produce.
Secret keys never cross the boundary. ContractKeyProvider derives funding
keys deterministically inside Rust from a mnemonic, seed, xprv, or private
descriptor. Callers only ever see public keys; the provider re-derives whatever
secret key an operation needs from the temporary contract id.
Messages cross as their lightning TLV encoding β the same bytes node-dlc and bitcoin-abstraction-layer produce. PSBTs are BIP-174; final transactions are Bitcoin consensus serialization.
offerer acceptor
β β
createOffer βββββββββββββββ OfferDlc βββββββββββββββΆ validateOffer
β β
β acceptOffer
β β
validateAccept ββββββββββββ AcceptDlc βββββββββββββββββ (+ funding PSBT)
β β
sign own funding inputs β
signAccept βββββββββββββββ SignDlc βββββββββββββββββββΆ validateSign
β β
β finalizeSign
β β
βββββββββββββ signed funding transaction ββββββββββββ
β
βββββββββββββββββββββ΄ββββββββββββββββββββ
signContractCet signContractRefund
(oracles attested β pays (locktime passed β returns
the attested outcome) each party its collateral)
Each party signs its own funding inputs on the shared PSBT before passing it
along β signFundingPsbtWithDescriptor does that from a private output
descriptor, or sign it with any wallet that speaks BIP-174.
Either party can settle on its own: the counterparty's half of the 2-of-2 comes from decrypting its CET adaptor signature with the oracle signatures.
Both packages expose these as free functions with identical names and argument
order, and both represent bytes as Uint8Array. In Node a Buffer is a
Uint8Array, so it can be passed anywhere bytes are taken.
import {
ContractKeyProvider,
chainHashFromNetwork,
fundingInput,
createOffer,
validateOffer,
acceptOffer,
validateAccept,
signFundingPsbtWithDescriptor,
signAccept,
validateSign,
finalizeSign,
computeContractId,
signContractCet,
signContractRefund,
} from '@bennyblader/ddk-ts'; // or '@bennyblader/ddk-rn'
// Keys stay in Rust. Only the funding pubkey comes out.
const offererKeys = ContractKeyProvider.fromDescriptor(OFFERER_DESCRIPTOR);
const acceptorKeys = ContractKeyProvider.fromMnemonic(
ACCEPTOR_MNEMONIC,
undefined,
'regtest'
);
const offerTempId = Buffer.alloc(32, 1); // 32 bytes, yours to choose and keep
const acceptTempId = Buffer.alloc(32, 2);
// 1. Offer
const offer = createOffer({
chainHash: chainHashFromNetwork('regtest'),
temporaryContractId: offerTempId,
contractInfo: CONTRACT_INFO, // wire-encoded ContractInfo
offerCollateralSats: 50_000n,
party: {
fundingPubkey: offererKeys.fundingPubkey(offerTempId),
fundingInputs: [
fundingInput(PREV_TX, 0, 100n, 0xffffffff, 108, Buffer.alloc(0)),
],
payoutSpk: OFFERER_SPK,
changeSpk: OFFERER_SPK,
},
feeRatePerVb: 2n,
cetLocktime: 0,
refundLocktime: 1_700_000_000,
contractFlags: 0,
});
// 2. Accept β returns the AcceptDlc, the unsigned transactions, and the funding PSBT
validateOffer(offer, 0, 4_294_967_295);
const accepted = acceptOffer(
offer,
{
party: {
fundingPubkey: acceptorKeys.fundingPubkey(acceptTempId),
fundingInputs: [/* the acceptor's UTXOs */],
payoutSpk: ACCEPTOR_SPK,
changeSpk: ACCEPTOR_SPK,
},
minTimeoutInterval: 0,
maxTimeoutInterval: 4_294_967_295,
},
acceptorKeys,
acceptTempId
);
// 3. Fund β each party signs its own funding inputs on the shared PSBT
validateAccept(offer, accepted.accept);
const psbt = signFundingPsbtWithDescriptor(
offer,
accepted.accept,
accepted.fundingPsbt,
OFFERER_DESCRIPTOR,
[{ inputSerialId: 100n, derivationIndex: 0 }]
);
// 4. Sign
const signed = signAccept(offer, accepted.accept, offererKeys, offerTempId, psbt);
validateSign(offer, accepted.accept, signed.sign);
const fundingTx = finalizeSign(offer, accepted.accept, signed.sign, psbt);
const contractId = computeContractId(offer, accepted.accept); // the funded contract's id
// 5. Settle β a CET once the oracles attestβ¦
const cet = signContractCet(
offer,
accepted.accept,
signed.sign,
offererKeys,
offerTempId, // the settling party's OWN temporary id β it also says which side is settling
[{ oracleIndex: 0, attestation: ATTESTATION }]
);
// β¦or the refund once refundLocktime passes
const refund = signContractRefund(
offer,
accepted.accept,
signed.sign,
offererKeys,
offerTempId
);Runnable versions of exactly this flow:
ddk-ts/example/src/contract.tsβpnpm contractddk-rn/example/src/App.tsxβ the on-device demo the Maestro E2E drives
ContractKeyProvider.fromMnemonic(mnemonic, passphrase, network);
ContractKeyProvider.fromSeed(seed, network);
ContractKeyProvider.fromXprv(xprv); // 78-byte encoded master xprv
ContractKeyProvider.fromDescriptor(descriptor); // must carry an xprv; watch-only is rejected
provider.fundingPubkey(temporaryContractId); // 33-byte compressed pubkeyThe only thing to persist per contract is its 32-byte temporaryContractId. The
provider re-derives the funding secret key from it whenever one is needed, so the
same provider serves every contract.
A contract can be rolled into a new one that spends its funding output directly, with no on-chain settlement in between. Only the offering party contributes the splice input.
const spliceInput = createDlcSpliceInput(
prevOffer,
prevAccept,
Party.Offer,
200n,
dlcInputMaxWitnessLen() // 220 β the required max witness length for a DLC input
);
// β¦place it in the offering party's `fundingInputs`, then use the spliced variants:
signAcceptSpliced(offer, accept, keys, tempId, psbt, [
{ inputSerialId: 200n, priorTemporaryContractId: prevTempId },
]);
finalizeSignSpliced(offer, accept, sign, psbt, keys, [
{ inputSerialId: 200n, priorTemporaryContractId: prevTempId },
]);The prior contract's funding key is re-derived inside Rust from the provider and the prior temporary id β like everything else, it never leaves.
The lifecycle functions validate internally, but each check is also exposed standalone so a stored or received message can be verified on its own:
| Function | Checks |
|---|---|
validateOffer(offer, minTimeout, maxTimeout) |
protocol version, funding inputs, fee rate, collateral, oracle timeouts |
validateAccept(offer, accept) |
the acceptor's CET adaptor signatures and refund signature |
validateSign(offer, accept, sign) |
the offerer's CET adaptor signatures and refund signature |
computeContractId(offer, accept) |
β returns the funded contract's 32-byte id |
contractInfoPayouts(contractInfo) |
β returns the payout table for display |
dlcTransactionsFromMessages(offer, accept) |
β rebuilds the unsigned fund/CET/refund transactions |
contractInfoPayouts handles both contract shapes: enum contracts yield one row
per labeled outcome, numeric contracts yield one row per inclusive
[rangeStart, rangeEnd] that shares a payout (isEnum says which).
Contract functions throw ContractError, whose variants are typed rather than
stringly: InvalidOffer, InvalidAccept, InvalidSign, InvalidFundingInput,
PsbtMismatch, MissingFinalizedInput, UnsupportedScriptType,
InvalidAttestation, NoMatchingOutcome, Descriptor, Wallet, Bip32,
Dlc, Key, Serialization, InvalidNetwork, InvalidLength.
Two worth calling out: a forged or misindexed attestation fails with
InvalidAttestation (attestations are verified against the announcements they
claim to come from), and an attested outcome no CET covers fails with
NoMatchingOutcome.
Both packages throw UniFFI's tagged union: switch on error.tag, with any
payload (e.g. { message }, { inputIndex }) under error.inner. The same
holds for the transaction API's DLCError.
The lower-level primitives remain available for building DLC transactions directly, without the message-driven flow.
Returns the version of the DDK library.
| Function | Purpose |
|---|---|
createDlcTransactions(outcomes, localParams, remoteParams, refundLocktime, feeRate, fundLockTime, cetLockTime, fundOutputSerialId, contractFlags) |
the complete set: funding, CETs, refund |
createSplicedDlcTransactions(β¦) |
the same, for a contract spending a prior DLC output |
createFundTxLockingScript(localFundPubkey, remoteFundPubkey) |
the 2-of-2 multisig locking script |
createCet(localOutput, localPayoutSerialId, remoteOutput, remotePayoutSerialId, fundTxId, fundVout, lockTime) |
one CET |
createCets(fundTxId, fundVout, localFinalScriptPubkey, remoteFinalScriptPubkey, outcomes, lockTime, localSerialId, remoteSerialId) |
a CET per outcome |
createRefundTransaction(localFinalScriptPubkey, remoteFinalScriptPubkey, localAmount, remoteAmount, lockTime, fundTxId, fundVout) |
the refund transaction |
createCetAdaptorSigsFromOracleInfo, createCetAdaptorSigsFromPoints,
createCetAdaptorPointsFromOracleInfo, verifyCetAdaptorSigsFromOracleInfo,
extractEcdsaSignatureFromOracleSignatures, plus the per-transaction operations
listed below.
convertMnemonicToSeed, createExtkeyFromSeed, createExtkeyFromParentPath,
createXprivFromParentPath, getPubkeyFromExtkey, getXpubFromXpriv.
A dozen operations are methods on a record rather than free functions,
because that is how ddk-ffi declares them. They are identical in both packages,
and the receiver is the first argument:
TxOutput.isDust(output) |
Transaction.signFundInput(tx, β¦) |
PartyParams.changeOutputAndFees(params, feeRate) |
Transaction.signMultiSigInput(tx, β¦) |
AdaptorSignature.verifyFromOracleInfo(sig, β¦) |
Transaction.signCet(cet, β¦) |
Transaction.addSignature(tx, β¦) |
Transaction.cetAdaptorSignatureFromOracleInfo(cet, β¦) |
Transaction.verifyFundSignature(tx, β¦) |
Transaction.cetAdaptorSignatureInputs(cet, β¦) |
Transaction.rawFundingInputSignature(tx, β¦) |
Transaction.cetSighash(cet, β¦) |
Everything under the contract API, and every function listed above it, is a free function in both.
// Contract API
interface CreateOfferParams {
chainHash: Bytes;
temporaryContractId?: Bytes; // random when omitted
contractInfo: Bytes; // wire-encoded ContractInfo
offerCollateralSats: bigint;
party: ContractPartyParams;
fundOutputSerialId?: bigint;
feeRatePerVb: bigint;
cetLocktime: number;
refundLocktime: number;
contractFlags: number; // 0 unless a protocol extension requires otherwise
}
interface ContractPartyParams {
fundingPubkey: Bytes; // 33-byte compressed
fundingInputs: Bytes[]; // each a wire-encoded FundingInput
payoutSpk: Bytes;
payoutSerialId?: bigint;
changeSpk: Bytes;
changeSerialId?: bigint;
}
interface AcceptOfferParams {
party: ContractPartyParams;
minTimeoutInterval: number;
maxTimeoutInterval: number;
}
interface AcceptResult {
accept: Bytes; // wire-encoded AcceptDlc
transactions: DlcTransactions;
fundingPsbt: Bytes; // BIP-174
}
interface SignResult {
sign: Bytes; // wire-encoded SignDlc
transactions: DlcTransactions;
}
interface SpliceKeyRef {
inputSerialId: bigint;
priorTemporaryContractId: Bytes;
}
interface OracleAttestationRef {
oracleIndex: number; // position in the contract info's announcements
attestation: Bytes; // wire-encoded OracleAttestation
}
interface DescriptorInput {
inputSerialId: bigint;
derivationIndex: number; // descriptor wildcard index
}
interface ContractPayouts {
totalCollateralSats: bigint;
isEnum: boolean;
rows: PayoutRow[];
}
interface PayoutRow {
outcome?: string; // enum contracts
rangeStart?: bigint; // numeric contracts
rangeEnd?: bigint;
offerPayoutSats: bigint;
acceptPayoutSats: bigint;
}
enum Party {
Offer,
Accept,
}
// Transaction API
interface Transaction {
version: number;
lockTime: number;
inputs: TxInput[];
outputs: TxOutput[];
rawBytes: Bytes;
}
interface TxOutput {
value: bigint;
scriptPubkey: Bytes;
}
interface TxInput {
txid: string;
vout: number;
scriptSig: Bytes;
sequence: number;
witness: Bytes[];
}
interface TxInputInfo {
txid: string;
vout: number;
scriptSig: Bytes;
maxWitnessLength: number;
serialId: bigint;
}
interface Payout {
offer: bigint;
accept: bigint;
}
interface PartyParams {
fundPubkey: Bytes;
changeScriptPubkey: Bytes;
changeSerialId: bigint;
payoutScriptPubkey: Bytes;
payoutSerialId: bigint;
inputs: TxInputInfo[];
inputAmount: bigint;
collateral: bigint;
dlcInputs: DlcInputInfo[];
}
interface DlcInputInfo {
fundTx: Transaction;
fundVout: number;
localFundPubkey: Bytes;
remoteFundPubkey: Bytes;
fundAmount: bigint;
maxWitnessLen: number;
inputSerialId: bigint;
contractId: Bytes;
}
interface DlcTransactions {
fund: Transaction;
cets: Transaction[];
refund: Transaction;
fundingWitnessScript: Bytes;
}
interface OracleInfo {
publicKey: Bytes;
nonces: Bytes[];
}
interface AdaptorSignature {
signature: Bytes;
proof: Bytes;
}
interface ChangeOutputAndFees {
changeOutput: TxOutput;
fundFee: bigint;
cetFee: bigint;
}Bytes is Uint8Array in both packages. A Node Buffer is a Uint8Array, so
ddk-ts takes one anywhere bytes are expected; returns are plain Uint8Array,
and Buffer.from(b.buffer, b.byteOffset, b.byteLength) re-wraps one zero-copy.
Both packages follow a pure wrapper approach around dlcdevkit and rust-dlc:
βββββββββββββββββββ ββββββββββββββββ βββββββββββββββ
β JavaScript β β Generated β β Rust β
β Application βββββΆβ Bindings βββββΆβ ddk / dlc β
β β β (TS + FFI) β β (Core) β
βββββββββββββββββββ ββββββββββββββββ βββββββββββββββ
ddk-ffi/src/ is the single source of truth for the interface. It is annotated
with UniFFI proc-macros (#[derive(uniffi::Record)], #[uniffi::export],
β¦) β there is no .udl file β and both packages are generated from the
compiled library by uniffi-bindgen-react-native: the JSI/C++ bindings for React
Native, and the N-API bindings for Node. Neither contains hand-written binding
code, so the Rust source and the generated TypeScript, C++, Swift, and Kotlin
cannot drift β from the crate or from each other.
That leaves one thing worth checking rather than three:
- CI regenerates
ddk-ts/srcand fails if it differs from what is committed ddk-rn/src/__tests__/contractBindings.test.jschecks that the generated JSI surface is complete β every function, record, and constructor present in both the TypeScript and the native symbol layerddk-ts/__test__/contract.spec.tsdrives the full lifecycle end to end, including a splice rollover and the failure modes
- Rust (latest stable)
- Node.js 20+
- pnpm
- Just (
cargo install just) uniffi-bindgen-react-nativeinstalled globally, at the version pinned inddk-rn/package.json(see CLAUDE.md on version lockstep)
.
βββ ddk-ffi/ # Rust crate β the UniFFI interface (proc-macros, no UDL)
β βββ src/
β β βββ lib.rs # transaction API
β β βββ contract.rs # stateless contract API
β βββ Cargo.toml
β
βββ ddk-ts/ # Node.js/TypeScript package (UniFFI + N-API)
β βββ src/ # generated TypeScript (committed, never hand-edited)
β βββ dist/ # tsc output β what the package ships
β βββ platform/ # one npm package per target, each with its cdylib
β βββ __test__/ # vitest suites
β βββ example/ # runnable examples
β βββ scripts/ # build + publish the generated package
β
βββ ddk-rn/ # React Native package (UniFFI + JSI)
β βββ src/ # generated TypeScript
β βββ cpp/ # generated C++ JSI bindings
β βββ ios/ # iOS native module + prebuilt XCFramework
β βββ android/ # Android native module + prebuilt JNI libraries
β βββ example/ # example app, driven by the Maestro E2E
β
βββ justfile # build automation
just check # cargo test (both crates) + ddk-rn typecheck
just lint # rustfmt + clippy + eslint
# TypeScript/Node.js
just ts-build # build for the current platform
just ts-build-all # build for all supported platforms
just ts-test # run tests
# React Native
just build # JSI + TurboModule bindings, iOS framework, and ddk-ts
just uniffi-jsi # regenerate TypeScript + C++ only
just build-ios # build the iOS XCFramework (release, stripped)
just build-android # build the Android JNI libraries (needs the NDK)
# End-to-end, on a real simulator/emulator
just e2e-flows # parse every Maestro flow β no device, no build (~15s)
just e2e-ios # build, install, and run the flows on iOS
just e2e-android # the same on Android (one-time: just e2e-android-setup)
# Release both packages (bumps versions, tags, pushes; CI publishes)
just release 0.5.0
just cleanAdding a new
#[uniffi::export]needsjust build-ios, not justjust uniffi-jsiβ the generated C++ calls into the XCFramework, and onlybuild-iosrebuilds it.
See DEVELOPMENT.md for the full workflow and release process.
MIT License - see LICENSE file for details.
Contributions welcome! Please ensure:
- All tests pass (
just check,just lint) - Bindings are regenerated when changing Rust code, and committed alongside it
- API parity between the two packages is maintained
- Documentation and the relevant
CHANGELOG.mdare updated
- GitHub: https://github.com/bennyhodl/ddk-ffi
- dlcdevkit: https://github.com/bennyhodl/dlcdevkit
- rust-dlc: https://github.com/p2pderivatives/rust-dlc
- UniFFI: https://mozilla.github.io/uniffi-rs/
- uniffi-bindgen-react-native: https://jhugman.github.io/uniffi-bindgen-react-native/
Built with β€οΈ using dlcdevkit