Install the SDK If you haven’t already, install the SDK and set up your environment
A Spilman Channel allows one party (Alice) to incrementally pay another (Bob) offchain using monotonically increasing state updates.
Path Condition When to use Collaborative update Alice + Bob + server signatures Instant offchain state updates Refund Alice + server signatures (after absolute timeout) Alice reclaims if Bob disappears Unilateral update Alice + Bob signatures (after exit delay) Close channel without server Unilateral refund Alice signature (after longer delay) Emergency fallback
Build the Tapscript
import { Script } from '@scure/btc-signer' ;
import { hex } from '@scure/base' ;
import {
RestArkProvider ,
SingleKey ,
VtxoScript ,
networks
} from '@arkade-os/sdk' ;
// Setup
const arkProvider = new RestArkProvider ( 'https://mutinynet.arkade.sh' );
const info = await arkProvider . getInfo ();
const serverPubkey = hex . decode ( info . signerPubkey ). slice ( 1 ); // x-only
const exitDelay = BigInt ( info . exitDelay );
// Identities
const alice = SingleKey . fromRandomBytes ();
const bob = SingleKey . fromRandomBytes ();
const alicePubkey = await alice . xOnlyPublicKey ();
const bobPubkey = await bob . xOnlyPublicKey ();
// Channel parameters
const refundTimeout = BigInt ( Math . floor ( Date . now () / 1000 )) + 86400 n ; // 1 day from now
const unilateralUpdateDelay = exitDelay ;
const unilateralRefundDelay = exitDelay + 2 n ; // Slightly longer than update delay
Collaborative update path (offchain, instant)
Alice and Bob agree on state, server co-signs:
< alicePubkey > OP_CHECKSIGVERIFY < bobPubkey > OP_CHECKSIGVERIFY < serverPubkey > OP_CHECKSIG
// Collaborative: aliceSig + bobSig + serverSig
const updatePath = Script . encode ([
alicePubkey ,
'CHECKSIGVERIFY' ,
bobPubkey ,
'CHECKSIGVERIFY' ,
serverPubkey ,
'CHECKSIG'
]);
Refund path (after timeout)
Alice can reclaim funds after the absolute timeout if Bob is unresponsive:
< refundTimeout > OP_CHECKLOCKTIMEVERIFY OP_DROP < alicePubkey > OP_CHECKSIGVERIFY < serverPubkey > OP_CHECKSIG
// Refund: after refundTimeout, aliceSig + serverSig
const refundPath = Script . encode ([
refundTimeout ,
'CHECKLOCKTIMEVERIFY' ,
'DROP' ,
alicePubkey ,
'CHECKSIGVERIFY' ,
serverPubkey ,
'CHECKSIG'
]);
Unilateral update path (onchain, timelocked)
Close channel without server after exit delay:
< unilateralUpdateDelay > OP_CHECKSEQUENCEVERIFY OP_DROP < alicePubkey > OP_CHECKSIGVERIFY < bobPubkey > OP_CHECKSIG
// Unilateral update: after exitDelay, aliceSig + bobSig
const unilateralUpdatePath = Script . encode ([
unilateralUpdateDelay ,
'CHECKSEQUENCEVERIFY' ,
'DROP' ,
alicePubkey ,
'CHECKSIGVERIFY' ,
bobPubkey ,
'CHECKSIG'
]);
Unilateral refund path (emergency fallback)
Alice can reclaim unilaterally after a longer delay:
< unilateralRefundDelay > OP_CHECKSEQUENCEVERIFY OP_DROP < alicePubkey > OP_CHECKSIG
// Unilateral refund: after longer delay, aliceSig only
const unilateralRefundPath = Script . encode ([
unilateralRefundDelay ,
'CHECKSEQUENCEVERIFY' ,
'DROP' ,
alicePubkey ,
'CHECKSIG'
]);
Assemble the VtxoScript
// Build the channel with all four paths
const channelScript = new VtxoScript ([
updatePath ,
refundPath ,
unilateralUpdatePath ,
unilateralRefundPath
]);
const channelAddress = channelScript . address ( networks . mutinynet . hrp , serverPubkey ). encode ();
console . log ( 'Channel address:' , channelAddress );
Channel state updates
Alice sends incrementally larger amounts to Bob by signing new transactions:
import { buildOffchainTx , CSVMultisigTapscript } from '@arkade-os/sdk' ;
const serverUnrollScript = CSVMultisigTapscript . decode (
hex . decode ( info . checkpointTapscript )
);
// Initial channel capacity (funded by Alice)
const channelCapacity = 100000 n ;
// Alice sends 1000 sats to Bob
const { arkTx : tx1 , checkpoints } = buildOffchainTx (
[{
txid: vtxo . txid ,
vout: vtxo . vout ,
value: vtxo . value ,
tapLeafScript: channelScript . findLeaf ( hex . encode ( updatePath )),
tapTree: channelScript . encode (),
}],
[
{ amount: 1000 n , script: bobScript . pkScript }, // Bob's output
{ amount: channelCapacity - 1000 n , script: channelScript . pkScript } // Change back to channel
],
serverUnrollScript
);
// Alice signs and sends to Bob (offchain, not submitted to server yet)
const signedByAlice = await alice . sign ( tx1 );
// Bob receives and stores this state
bobChannelStates . push ( await bob . sign ( signedByAlice ));
// Later: Alice sends 500 more sats (total: 1500 to Bob)
const { arkTx : tx2 } = buildOffchainTx (
[ /* same input */ ],
[
{ amount: 1500 n , script: bobScript . pkScript },
{ amount: channelCapacity - 1500 n , script: channelScript . pkScript }
],
serverUnrollScript
);
const signedTx2 = await alice . sign ( tx2 );
bobChannelStates . push ( await bob . sign ( signedTx2 ));
Bob only keeps the latest state. Each new transaction pays Bob more than the previous one, so older states are worthless to him.
Closing the channel
Bob closes by submitting the latest signed state to the server:
import { Transaction } from '@arkade-os/sdk' ;
import { base64 } from '@scure/base' ;
// Bob takes the latest state and submits it
const latestState = bobChannelStates [ bobChannelStates . length - 1 ];
const { arkTxid , signedCheckpointTxs } = await arkProvider . submitTx (
base64 . encode ( latestState . toPSBT ()),
checkpoints . map ( c => base64 . encode ( c . toPSBT ()))
);
// Finalize checkpoints (both Alice and Bob sign)
const finalCheckpoints = await Promise . all (
signedCheckpointTxs . map ( async ( cpB64 ) => {
const cpTx = Transaction . fromPSBT ( base64 . decode ( cpB64 ));
const signedByAlice = await alice . sign ( cpTx , [ 0 ]);
const signedByBoth = await bob . sign (
Transaction . fromPSBT ( signedByAlice . toPSBT ()),
[ 0 ]
);
return base64 . encode ( signedByBoth . toPSBT ());
})
);
await arkProvider . finalizeTx ( arkTxid , finalCheckpoints );
console . log ( 'Channel closed!' );
Script breakdown
Opcode Effect CHECKSIGVERIFYVerify signature, continue if valid CHECKSIGFinal signature check, return result CHECKLOCKTIMEVERIFYEnforce absolute timelock (CLTV) CHECKSEQUENCEVERIFYEnforce relative timelock (CSV) DROPRemove timelock value from stack
Timelock ordering
The timelocks must be ordered: unilateralUpdateDelay < unilateralRefundDelay
This ensures Bob can always close with the latest valid state before Alice’s refund becomes valid.
Next steps