skills/arkd-offchain-tx/SKILL.md
Off-chain transaction processing in arkd - Ark TX and checkpoint TX construction, submission, and finalization
npx skillsauth add arklabshq/arkadian arkd-offchain-txInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill when:
┌─────────────────────────────────────────────────────────────────┐
│ OFF-CHAIN TRANSACTION FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client Server (arkd) │
│ ────── ───────────── │
│ │
│ 1. Build Ark TX + Checkpoint TXs │
│ │ │
│ ▼ │
│ 2. Sign Ark TX (forfeit path) │
│ │ │
│ └──────────────────────────────► SubmitOffchainTx() │
│ │ │
│ ▼ │
│ 3. Validate inputs │
│ 4. Co-sign Ark TX │
│ 5. Sign Checkpoint TXs │
│ ◄─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 6. Verify server signatures │
│ 7. Sign checkpoint TXs (user side) │
│ │ │
│ └──────────────────────────────► FinalizeOffchainTx() │
│ │ │
│ ▼ │
│ 8. Store final TXs │
│ 9. Update VTXO state │
│ ◄─────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 10. Update local VTXO state │
│ │
└─────────────────────────────────────────────────────────────────┘
| Transaction | Purpose | Description | |-------------|---------|-------------| | Ark TX | Off-chain payment | Spends checkpoint outputs to new VTXOs | | Checkpoint TX | Intermediate step | Spends VTXO using forfeit path to checkpoint output |
| Stage | Code | Description |
|-------|------|-------------|
| OffchainTxUndefinedStage | 0 | Initial state |
| OffchainTxRequestedStage | 1 | Client submitted, awaiting co-sign |
| OffchainTxAcceptedStage | 2 | Server co-signed, awaiting finalization |
| OffchainTxFinalizedStage | 3 | Fully signed and stored |
type VtxoInput struct {
Outpoint *wire.OutPoint
Amount int64
// Tapscript is the path used to spend the VTXO (forfeit closure)
Tapscript *waddrmgr.Tapscript
// RevealedTapscripts is the whole taproot tree of the VTXO
// Must be revealed to ASP to verify spend paths are valid
RevealedTapscripts []string
}
Source: arkd/pkg/ark-lib/offchain/tx.go:20-28
// BuildTxs builds the ark and checkpoint txs for given inputs and outputs.
func BuildTxs(
vtxos []VtxoInput, // VTXOs to spend
outputs []*wire.TxOut, // New VTXO outputs
signerUnrollScript []byte, // ASP's unroll script
) (*psbt.Packet, []*psbt.Packet, error) {
checkpointInputs := make([]VtxoInput, 0, len(vtxos))
checkpointTxs := make([]*psbt.Packet, 0, len(vtxos))
inputAmount := int64(0)
// Decode the signer unroll script
signerUnrollScriptClosure := &script.CSVMultisigClosure{}
valid, err := signerUnrollScriptClosure.Decode(signerUnrollScript)
if err != nil || !valid {
return nil, nil, fmt.Errorf("invalid signer unroll script")
}
// Build checkpoint tx for each VTXO
for _, vtxo := range vtxos {
checkpointPtx, checkpointInput, err := buildCheckpointTx(
vtxo, signerUnrollScriptClosure,
)
if err != nil {
return nil, nil, err
}
checkpointInputs = append(checkpointInputs, *checkpointInput)
checkpointTxs = append(checkpointTxs, checkpointPtx)
inputAmount += vtxo.Amount
}
// Verify input/output balance
outputAmount := int64(0)
for _, output := range outputs {
outputAmount += output.Value
}
if inputAmount != outputAmount {
return nil, nil, fmt.Errorf("input amount is not equal to output amount")
}
// Build the Ark TX spending checkpoint outputs
arkTx, err := buildArkTx(checkpointInputs, outputs)
if err != nil {
return nil, nil, err
}
return arkTx, checkpointTxs, nil
}
Source: arkd/pkg/ark-lib/offchain/tx.go:31-73
// buildCheckpointTx creates a virtual tx sending to a "checkpoint" VTXO script.
// Checkpoint = signer unroll script + owner's collaborative closure
func buildCheckpointTx(
vtxo VtxoInput, signerUnrollScript *script.CSVMultisigClosure,
) (*psbt.Packet, *VtxoInput, error) {
// Get owner's collaborative closure from the VTXO tapscript
collaborativeClosure, err := script.DecodeClosure(vtxo.Tapscript.RevealedScript)
if err != nil {
return nil, nil, err
}
// Create checkpoint VTXO script: [signer unroll, owner collaborative]
checkpointVtxoScript := script.TapscriptsVtxoScript{
Closures: []script.Closure{signerUnrollScript, collaborativeClosure},
}
tapKey, tapTree, err := checkpointVtxoScript.TapTree()
if err != nil {
return nil, nil, err
}
checkpointPkScript, err := script.P2TRScript(tapKey)
if err != nil {
return nil, nil, err
}
// Build the checkpoint virtual tx
checkpointPtx, err := buildArkTx(
[]VtxoInput{vtxo},
[]*wire.TxOut{{Value: vtxo.Amount, PkScript: checkpointPkScript}},
)
if err != nil {
return nil, nil, err
}
// Create input for Ark TX that spends this checkpoint output
tapLeafHash := txscript.NewBaseTapLeaf(vtxo.Tapscript.RevealedScript).TapHash()
collaborativeLeafProof, _ := tapTree.GetTaprootMerkleProof(tapLeafHash)
ctrlBlock, _ := txscript.ParseControlBlock(collaborativeLeafProof.ControlBlock)
revealedTapscripts, _ := checkpointVtxoScript.Encode()
checkpointInput := &VtxoInput{
Outpoint: &wire.OutPoint{
Hash: checkpointPtx.UnsignedTx.TxHash(),
Index: 0,
},
Amount: vtxo.Amount,
Tapscript: &waddrmgr.Tapscript{
ControlBlock: ctrlBlock,
RevealedScript: collaborativeLeafProof.Script,
},
RevealedTapscripts: revealedTapscripts,
}
return checkpointPtx, checkpointInput, nil
}
Source: arkd/pkg/ark-lib/offchain/tx.go:173-239
// buildArkTx builds an ark tx spending VTXOs via collaborative taproot path.
func buildArkTx(vtxos []VtxoInput, outputs []*wire.TxOut) (*psbt.Packet, error) {
if len(vtxos) <= 0 {
return nil, fmt.Errorf("missing vtxos")
}
ins := make([]*wire.OutPoint, 0, len(vtxos))
sequences := make([]uint32, 0, len(vtxos))
witnessUtxos := make(map[int]*wire.TxOut)
signingTapLeaves := make(map[int]*psbt.TaprootTapLeafScript)
tapscripts := make(map[int][]string)
txLocktime := common.AbsoluteLocktime(0)
for index, vtxo := range vtxos {
tapscripts[index] = vtxo.RevealedTapscripts
// Compute the VTXO output script
rootHash := vtxo.Tapscript.ControlBlock.RootHash(vtxo.Tapscript.RevealedScript)
taprootKey := txscript.ComputeTaprootOutputKey(script.UnspendableKey(), rootHash)
vtxoOutputScript, _ := script.P2TRScript(taprootKey)
witnessUtxos[index] = &wire.TxOut{
Value: vtxo.Amount,
PkScript: vtxoOutputScript,
}
ctrlBlockBytes, _ := vtxo.Tapscript.ControlBlock.ToBytes()
signingTapLeaves[index] = &psbt.TaprootTapLeafScript{
ControlBlock: ctrlBlockBytes,
Script: vtxo.Tapscript.RevealedScript,
LeafVersion: txscript.BaseLeafVersion,
}
// Handle CLTV multisig closure (updates tx locktime)
closure, _ := script.DecodeClosure(vtxo.Tapscript.RevealedScript)
if cltv, ok := closure.(*script.CLTVMultisigClosure); ok {
if cltv.Locktime > txLocktime {
txLocktime = cltv.Locktime
}
sequences = append(sequences, cltvSequence)
} else {
sequences = append(sequences, wire.MaxTxInSequenceNum)
}
ins = append(ins, vtxo.Outpoint)
}
// Create PSBT with anchor output
arkTx, _ := psbt.New(
ins, append(outputs, txutils.AnchorOutput()), 3, uint32(txLocktime), sequences,
)
// Set input fields
for i := range arkTx.Inputs {
arkTx.Inputs[i].WitnessUtxo = witnessUtxos[i]
arkTx.Inputs[i].TaprootLeafScript = []*psbt.TaprootTapLeafScript{
signingTapLeaves[i],
}
txutils.SetArkPsbtField(arkTx, i, txutils.VtxoTaprootTreeField, tapscripts[i])
}
return arkTx, nil
}
Source: arkd/pkg/ark-lib/offchain/tx.go:75-171
type OffchainTx struct {
Stage Stage // Current stage
StartingTimestamp int64 // When tx was submitted
EndingTimestamp int64 // When tx was finalized
ArkTxid string // Ark TX ID
ArkTx string // Ark TX (PSBT)
CheckpointTxs map[string]string // txid -> checkpoint tx
CommitmentTxids map[string]string // checkpoint txid -> commitment txid
RootCommitmentTxId string // Root commitment txid
ExpiryTimestamp int64 // When VTXOs expire
FailReason string // Failure reason
Version uint // Event version
changes []Event // Domain events
}
Source: arkd/internal/core/domain/offchain_tx.go:35-48
func (s *OffchainTx) Request(
arkTxid, arkTx string, unsignedCheckpointTxs map[string]string,
) (Event, error) {
if s.IsFailed() || s.Stage.Code != int(OffchainTxUndefinedStage) {
return nil, fmt.Errorf("not in a valid stage to request offchain tx")
}
if arkTxid == "" || arkTx == "" {
return nil, fmt.Errorf("missing ark tx")
}
if len(unsignedCheckpointTxs) == 0 {
return nil, fmt.Errorf("missing unsigned checkpoint txs")
}
event := OffchainTxRequested{
OffchainTxEvent: OffchainTxEvent{
Id: arkTxid,
Type: EventTypeOffchainTxRequested,
},
ArkTx: arkTx,
UnsignedCheckpointTxs: unsignedCheckpointTxs,
StartingTimestamp: time.Now().Unix(),
}
s.raise(event)
return event, nil
}
Source: arkd/internal/core/domain/offchain_tx.go:68-95
func (s *OffchainTx) Accept(
finalArkTx string, signedCheckpointTxs map[string]string,
commitmentTxsByCheckpointTxid map[string]string,
rootCommitmentTx string, expiryTimestamp int64,
) (Event, error) {
if finalArkTx == "" {
return nil, fmt.Errorf("missing final ark tx")
}
if len(signedCheckpointTxs) != len(s.CheckpointTxs) {
return nil, fmt.Errorf("invalid number of signed checkpoint txs")
}
if !s.IsRequested() {
return nil, fmt.Errorf("not in a valid stage to accept offchain tx")
}
event := OffchainTxAccepted{
OffchainTxEvent: OffchainTxEvent{
Id: s.ArkTxid,
Type: EventTypeOffchainTxAccepted,
},
FinalArkTx: finalArkTx,
SignedCheckpointTxs: signedCheckpointTxs,
CommitmentTxids: commitmentTxsByCheckpointTxid,
RootCommitmentTxid: rootCommitmentTx,
ExpiryTimestamp: expiryTimestamp,
}
s.raise(event)
return event, nil
}
Source: arkd/internal/core/domain/offchain_tx.go:97-138
func (s *OffchainTx) Finalize(finalCheckpointTxs map[string]string) (Event, error) {
if len(finalCheckpointTxs) != len(s.CheckpointTxs) {
return nil, fmt.Errorf("invalid number of final checkpoint txs")
}
// Verify all checkpoint txs are present
for txid := range s.CheckpointTxs {
if _, ok := finalCheckpointTxs[txid]; !ok {
return nil, fmt.Errorf("checkpoint tx %s not found", txid)
}
}
if !s.IsAccepted() {
return nil, fmt.Errorf("not in a valid stage to finalize offchain tx")
}
event := OffchainTxFinalized{
OffchainTxEvent: OffchainTxEvent{
Id: s.ArkTxid,
Type: EventTypeOffchainTxFinalized,
},
FinalCheckpointTxs: finalCheckpointTxs,
Timestamp: time.Now().Unix(),
}
s.raise(event)
return event, nil
}
Source: arkd/internal/core/domain/offchain_tx.go:140-172
func (s *service) SubmitOffchainTx(
ctx context.Context, unsignedCheckpointTxs []string, signedArkTx string,
) (acceptedTx *AcceptedOffchainTx, structErr errors.Error) {
arkPtx, _ := psbt.NewFromRawBytes(strings.NewReader(signedArkTx), true)
txid := arkPtx.UnsignedTx.TxID()
offchainTx := domain.NewOffchainTx()
var changes []domain.Event
defer func() {
if structErr != nil {
change := offchainTx.Fail(structErr)
changes = append(changes, change)
}
// Save events
s.repoManager.Events().Save(ctx, domain.OffchainTxTopic, txid, changes)
}()
// Validate and process each input VTXO
ins := make([]offchain.VtxoInput, 0)
for i, input := range arkPtx.UnsignedTx.TxIn {
vtxoOutpoint := domain.Outpoint{
Txid: input.PreviousOutPoint.Hash.String(),
VOut: input.PreviousOutPoint.Index,
}
// Look up VTXO commitment chain
vtxosResult, _ := s.repoManager.Vtxos().GetVtxos(ctx, []domain.Outpoint{vtxoOutpoint})
vtxo := vtxosResult[0]
// Verify VTXO is spendable
if vtxo.Spent {
return nil, errors.VTXO_ALREADY_SPENT.New("vtxo %s already spent", vtxoOutpoint)
}
// Build VtxoInput for validation
ins = append(ins, buildVtxoInput(vtxo, arkPtx.Inputs[i]))
}
// Validate checkpoint txs match the inputs
// ...
// Co-sign the Ark TX
signedArkTx, _ := s.signer.SignArkTx(ctx, signedArkTx)
// Sign checkpoint txs
signedCheckpointTxs := make(map[string]string)
for _, checkpointTx := range unsignedCheckpointTxs {
signed, _ := s.signer.SignCheckpointTx(ctx, checkpointTx)
signedCheckpointTxs[signed.TxID()] = signed
}
// Accept the offchain tx
event, _ := offchainTx.Accept(
signedArkTx, signedCheckpointTxs,
commitmentTxids, rootCommitmentTxid, expiryTimestamp,
)
changes = append(changes, event)
// Store in cache for finalization
s.cache.OffchainTxs().Add(ctx, offchainTx)
return &AcceptedOffchainTx{
Txid: txid,
FinalArkTx: signedArkTx,
SignedCheckpointTxs: signedCheckpointTxs,
}, nil
}
Source: arkd/internal/core/application/service.go:412-1114 (simplified)
func (s *service) FinalizeOffchainTx(
ctx context.Context, txid string, finalCheckpointTxs []string,
) (structErr errors.Error) {
var changes []domain.Event
// Get offchain tx from cache or repo
offchainTx, err := s.cache.OffchainTxs().Get(ctx, txid)
if err != nil || offchainTx == nil {
offchainTx, _ = s.repoManager.OffchainTxs().GetOffchainTx(ctx, txid)
}
defer func() {
if structErr != nil {
change := offchainTx.Fail(structErr)
changes = append(changes, change)
}
s.cache.OffchainTxs().Remove(ctx, txid)
s.repoManager.Events().Save(ctx, domain.OffchainTxTopic, txid, changes)
}()
// Build map of final checkpoint txs
finalCheckpointTxsMap := make(map[string]string)
for _, tx := range finalCheckpointTxs {
ptx, _ := psbt.NewFromRawBytes(strings.NewReader(tx), true)
finalCheckpointTxsMap[ptx.UnsignedTx.TxID()] = tx
}
// Verify all checkpoint signatures are valid
for _, tx := range finalCheckpointTxs {
if err := verifyCheckpointSignature(tx); err != nil {
return errors.INVALID_SIGNATURE.New("invalid checkpoint signature")
}
}
// Finalize the offchain tx
event, _ := offchainTx.Finalize(finalCheckpointTxsMap)
changes = append(changes, event)
// Mark input VTXOs as spent
for _, vtxo := range getInputVtxos(offchainTx) {
vtxo.Spent = true
vtxo.ArkTxid = offchainTx.ArkTxid
s.repoManager.Vtxos().UpdateVtxos(ctx, []domain.Vtxo{vtxo})
}
// Create new VTXOs from outputs
newVtxos := extractOutputVtxos(offchainTx)
s.repoManager.Vtxos().AddVtxos(ctx, newVtxos)
return nil
}
Source: arkd/internal/core/application/service.go:1116-1258 (simplified)
type OffchainTxRequested struct {
OffchainTxEvent
ArkTx string // Unsigned ark tx
UnsignedCheckpointTxs map[string]string // txid -> unsigned checkpoint
StartingTimestamp int64
}
type OffchainTxAccepted struct {
OffchainTxEvent
CommitmentTxids map[string]string // checkpoint txid -> commitment txid
RootCommitmentTxid string // Root commitment txid
FinalArkTx string // Co-signed ark tx
SignedCheckpointTxs map[string]string // txid -> server-signed checkpoint
ExpiryTimestamp int64
}
type OffchainTxFinalized struct {
OffchainTxEvent
FinalCheckpointTxs map[string]string // txid -> fully signed checkpoint
Timestamp int64
}
type OffchainTxFailed struct {
OffchainTxEvent
Reason string
Timestamp int64
}
Source: arkd/internal/core/domain/offchain_tx_event.go:13-39
func (s *OffchainTx) on(event Event, replayed bool) {
switch e := event.(type) {
case OffchainTxRequested:
if s.Stage.Code != int(OffchainTxUndefinedStage) || s.Stage.Failed {
return
}
s.Stage.Code = int(OffchainTxRequestedStage)
s.ArkTxid = e.Id
s.ArkTx = e.ArkTx
s.CheckpointTxs = e.UnsignedCheckpointTxs
s.StartingTimestamp = e.StartingTimestamp
case OffchainTxAccepted:
if s.Stage.Code != int(OffchainTxRequestedStage) || s.Stage.Failed {
return
}
s.Stage.Code = int(OffchainTxAcceptedStage)
s.ArkTx = e.FinalArkTx
s.CheckpointTxs = e.SignedCheckpointTxs
s.CommitmentTxids = e.CommitmentTxids
s.RootCommitmentTxId = e.RootCommitmentTxid
s.ExpiryTimestamp = e.ExpiryTimestamp
case OffchainTxFinalized:
if s.Stage.Code != int(OffchainTxAcceptedStage) {
return
}
s.Stage.Code = int(OffchainTxFinalizedStage)
s.Stage.Ended = true
s.CheckpointTxs = e.FinalCheckpointTxs
s.EndingTimestamp = e.Timestamp
case OffchainTxFailed:
if s.Stage.Code == int(OffchainTxFinalizedStage) || s.Stage.Failed {
return
}
s.Stage.Failed = true
s.FailReason = e.Reason
}
if replayed {
s.Version++
}
}
Source: arkd/internal/core/domain/offchain_tx.go:227-269
| Purpose | File | Key Types/Functions |
|---------|------|---------------------|
| Off-chain tx building | arkd/pkg/ark-lib/offchain/tx.go | BuildTxs, VtxoInput, buildArkTx, buildCheckpointTx |
| Domain model | arkd/internal/core/domain/offchain_tx.go | OffchainTx, Request, Accept, Finalize |
| Domain events | arkd/internal/core/domain/offchain_tx_event.go | OffchainTxRequested, OffchainTxAccepted, etc. |
| Service layer | arkd/internal/core/application/service.go | SubmitOffchainTx, FinalizeOffchainTx |
| Repository | arkd/internal/core/domain/offchain_tx_repo.go | OffchainTxRepository interface |
// In go-sdk client.go (SendOffChain)
func buildOffchainTx(inputs []coin, receivers []Receiver, checkpointExitPath []byte) (*psbt.Packet, []*psbt.Packet, error) {
vtxos := make([]offchain.VtxoInput, 0)
for _, in := range inputs {
vtxos = append(vtxos, offchain.VtxoInput{
Outpoint: in.Outpoint,
Amount: int64(in.Amount),
Tapscript: in.Tapscript,
RevealedTapscripts: in.RevealedTapscripts,
})
}
outputs := make([]*wire.TxOut, 0)
for _, r := range receivers {
outputs = append(outputs, &wire.TxOut{
Value: int64(r.Amount),
PkScript: r.Script,
})
}
return offchain.BuildTxs(vtxos, outputs, checkpointExitPath)
}
func isPending(offchainTx *OffchainTx) bool {
return offchainTx.IsRequested() || offchainTx.IsAccepted()
}
func getCommitmentChain(offchainTx *OffchainTx, checkpointTxid string) []string {
commitmentTxid := offchainTx.CommitmentTxids[checkpointTxid]
return []string{offchainTx.RootCommitmentTxId, commitmentTxid}
}
Checkpoint TX Purpose: Checkpoint TXs add an intermediate step that allows the ASP to unroll if the user misbehaves. They're virtual transactions (never broadcast).
Input/Output Balance: The sum of inputs MUST equal sum of outputs (no fees within Ark TX, fees handled by anchors).
CLTV Handling: If any input uses CLTV closure, the tx locktime must be set appropriately and sequence must be cltvSequence.
Revealed Tapscripts: The full taproot tree must be revealed to the ASP for validation. Don't skip this.
Concurrent Spending: Check s.cache.OffchainTxs().Includes() before accepting to prevent double-spending.
Expiry Inheritance: New VTXOs inherit the earliest expiry from input VTXOs (or input commitment chain).
Event Sourcing: OffchainTx is event-sourced. All state changes through events, replay reconstructs state.
Version 3 Transactions: All Ark and checkpoint transactions must be version 3 for TRUC/v3 relay.
Server Co-signing: The ASP signs AFTER the user. Verify user signatures before co-signing.
Finalization Window: Off-chain TXs should be finalized promptly. The cache holds pending TXs temporarily.
Skill Owner: ark-developer Repos: arkd, go-sdk
documentation
Update project documentation based on new commits and changes in the repository. Use when: user wants to sync docs after project changes.
testing
Remove a project from the Arkadian documentation registry and delete all associated documentation files. Use when: user wants to deregister a project.
tools
RESTRICTED to ark-project-manager. Generate actionable, dependency-ordered task lists organized by user story.
testing
RESTRICTED to ark-project-manager. Create or update feature specifications from natural language descriptions.