| name | ark-vtxo-model |
| description | VTXO lifecycle, domain model, states, expiration, and tree construction in Ark protocol |
VTXO Model for Ark
When to Use
Use this skill when:
- Working with VTXO creation, storage, or retrieval
- Understanding VTXO state transitions (spent, swept, settled, unrolled)
- Handling VTXO expiration and refresh logic
- Building or interpreting VTXO trees
- Implementing VTXO spending paths (forfeit vs unilateral exit)
- Converting between server-side and client-side VTXO representations
Key Concepts
1. What is a VTXO?
A VTXO (Virtual Transaction Output) is an off-chain output in Ark:
- Represents a balance owned by a user
- Backed by an on-chain commitment transaction
- Has an expiration time (must be refreshed or settled)
- Can be spent off-chain (fast) or on-chain (unilateral exit)
2. VTXO Script Structure
Standard VTXO: Owner + ASP | Owner after Timeout
Taproot Output
├── Key path: Unspendable (NUMS point)
└── Script tree:
├── Leaf 0 (Forfeit): Owner + ASP multisig
└── Leaf 1 (Exit): Owner after CSV delay
- Forfeit path: Owner and ASP sign together (collaborative spending)
- Exit path: Owner alone after timeout (unilateral exit)
3. VTXO States
| State | Description |
|---|
| Created | VTXO exists, not yet settled on-chain |
| Preconfirmed | Created in a round, commitment not yet mined |
| Settled | Commitment tx confirmed on-chain |
| Spent | Used as input to another Ark transaction |
| Swept | ASP reclaimed after expiration |
| Unrolled | User performed unilateral exit |
4. VTXO vs Note
- VTXO: Has commitment chain (tree path), requires forfeit tx when spent
- Note: Direct output, no commitment chain, simpler spending
Code Patterns
Pattern 1: Server-Side VTXO Domain Model (arkd)
type Vtxo struct {
Outpoint
Amount uint64
PubKey string
CommitmentTxids []string
RootCommitmentTxid string
SettledBy string
SpentBy string
ArkTxid string
Spent bool
Unrolled bool
Swept bool
Preconfirmed bool
ExpiresAt int64
CreatedAt int64
}
Source: arkd/internal/core/domain/vtxo.go:38-53
Pattern 2: Client-Side VTXO Model (go-sdk)
type Vtxo struct {
Outpoint
Script string
Amount uint64
CommitmentTxids []string
ExpiresAt time.Time
CreatedAt time.Time
Preconfirmed bool
Swept bool
Unrolled bool
Spent bool
SpentBy string
SettledBy string
ArkTxid string
}
Source: go-sdk/types/types.go:74-88
Pattern 3: Outpoint Identifier
type Outpoint struct {
Txid string
VOut uint32
}
func (k *Outpoint) FromString(s string) error {
parts := strings.Split(s, ":")
if len(parts) != 2 {
return fmt.Errorf("invalid outpoint string: %s", s)
}
k.Txid = parts[0]
vout, _ := strconv.ParseUint(parts[1], 10, 32)
k.VOut = uint32(vout)
return nil
}
func (k Outpoint) String() string {
return fmt.Sprintf("%s:%d", k.Txid, k.VOut)
}
Source: arkd/internal/core/domain/vtxo.go:15-36
Pattern 4: Checking if VTXO is a Note
func (v Vtxo) IsNote() bool {
return len(v.CommitmentTxids) <= 0 && v.RootCommitmentTxid == ""
}
Source: arkd/internal/core/domain/vtxo.go:61-63
Pattern 5: Checking if VTXO Requires Forfeit
func (v Vtxo) RequiresForfeit() bool {
return !v.Swept && !v.IsNote()
}
Source: arkd/internal/core/domain/vtxo.go:65-67
Pattern 6: Getting VTXO Taproot Key
func (v Vtxo) TapKey() (*btcec.PublicKey, error) {
pubkeyBytes, err := hex.DecodeString(v.PubKey)
if err != nil {
return nil, err
}
return schnorr.ParsePubKey(pubkeyBytes)
}
Source: arkd/internal/core/domain/vtxo.go:73-79
Pattern 7: Getting VTXO Output Script
func (v Vtxo) OutputScript() ([]byte, error) {
pubkey, err := v.TapKey()
if err != nil {
return nil, err
}
return script.P2TRScript(pubkey)
}
Source: arkd/internal/core/domain/vtxo.go:81-87
Pattern 8: VTXO is Settled Check
func (v Vtxo) IsSettled() bool {
return v.SettledBy != ""
}
Source: arkd/internal/core/domain/vtxo.go:69-71
Pattern 9: VTXO is Recoverable (Client)
func (v Vtxo) IsRecoverable() bool {
return v.Swept && !v.Spent
}
Source: go-sdk/types/types.go:96-98
Pattern 10: Getting Ark Address from VTXO
func (v Vtxo) Address(server *btcec.PublicKey, net arklib.Network) (string, error) {
buf, _ := hex.DecodeString(v.Script)
pubkeyBytes := buf[2:]
pubkey, _ := schnorr.ParsePubKey(pubkeyBytes)
a := &arklib.Address{
HRP: net.Addr,
Signer: server,
VtxoTapKey: pubkey,
}
return a.EncodeV0()
}
Source: go-sdk/types/types.go:100-119
Pattern 11: Round and VTXO Relationship
type Round struct {
Id string
Stage Stage
Intents map[string]Intent
CommitmentTxid string
CommitmentTx string
VtxoTree tree.FlatTxTree
Connectors tree.FlatTxTree
VtxoTreeExpiration int64
}
Source: arkd/internal/core/domain/round.go:41-59
Pattern 12: Round Stages
const (
RoundUndefinedStage RoundStage = iota
RoundRegistrationStage
RoundFinalizationStage
)
func (r *Round) IsStarted() bool {
return !r.IsFailed() && !r.IsEnded() && r.Stage != empty
}
func (r *Round) IsEnded() bool {
return !r.IsFailed() && r.Stage.Code == int(RoundFinalizationStage) && r.Stage.Ended
}
func (r *Round) IsFailed() bool {
return r.Stage.Failed
}
Source: arkd/internal/core/domain/round.go:11-16, 249-260
Pattern 13: VTXO Tree Expiration
func (r *Round) ExpiryTimestamp() int64 {
if r.IsEnded() {
return time.Unix(r.EndingTimestamp, 0).Add(
time.Second * time.Duration(r.VtxoTreeExpiration),
).Unix()
}
return -1
}
Source: arkd/internal/core/domain/round.go:262-269
Pattern 14: VTXO Event Types (Client)
const (
VtxosAdded VtxoEventType = iota
VtxosSpent
VtxosUpdated
)
type VtxoEvent struct {
Type VtxoEventType
Vtxos []Vtxo
}
Source: go-sdk/types/types.go:144-163
Pattern 15: Forfeit Transaction
type ForfeitTx struct {
Txid string
Tx string
}
func (r *Round) EndFinalization(forfeitTxs []ForfeitTx, finalCommitmentTx string) ([]Event, error) {
if len(forfeitTxs) <= 0 {
for _, intent := range r.Intents {
for _, in := range intent.Inputs {
if in.RequiresForfeit() {
return nil, fmt.Errorf("missing list of signed forfeit txs")
}
}
}
}
}
Source: arkd/internal/core/domain/round.go:36-39, 165-176
File References
| Purpose | File | Key Types/Functions |
|---|
| Server VTXO model | arkd/internal/core/domain/vtxo.go | Vtxo, Outpoint, IsNote, RequiresForfeit |
| Client VTXO model | go-sdk/types/types.go | Vtxo, VtxoEvent, VtxoEventType |
| Round domain | arkd/internal/core/domain/round.go | Round, RoundStage, ForfeitTx |
| VTXO script | arkd/pkg/ark-lib/script/vtxo_script.go | VtxoScript, DefaultVtxoScript |
| Tree structure | arkd/pkg/ark-lib/tree/tx_tree.go | TxTree, FlatTxTree |
Common Operations
Operation 1: Check if VTXO can be spent off-chain
func canSpendOffchain(vtxo Vtxo) bool {
return !vtxo.Spent &&
!vtxo.Swept &&
!vtxo.Unrolled &&
vtxo.IsSettled()
}
Operation 2: Calculate VTXO expiration
expiresAt := round.EndingTimestamp + round.VtxoTreeExpiration
Operation 3: Determine spending path
func getSpendingPath(vtxo Vtxo) string {
if vtxo.IsNote() {
return "direct"
}
if vtxo.Swept {
return "unilateral_exit"
}
return "forfeit"
}
Operation 4: Create VTXO from round output
func NewVtxoFromRound(round *Round, outpoint Outpoint, amount uint64, pubkey string) Vtxo {
return Vtxo{
Outpoint: outpoint,
Amount: amount,
PubKey: pubkey,
RootCommitmentTxid: round.CommitmentTxid,
ExpiresAt: round.ExpiryTimestamp(),
CreatedAt: round.EndingTimestamp,
Preconfirmed: true,
}
}
VTXO Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ VTXO LIFECYCLE │
├─────────────────────────────────────────────────────────────┤
│ │
│ User Intent ──► Round Registration ──► Tree Construction │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ PRECONFIRMED │ │
│ │ (commitment tx in mempool, not confirmed) │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (commitment confirmed) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ SETTLED │ │
│ │ (on-chain, spendable, has expiration) │ │
│ └────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌─────────┐ ┌──────────┐ │
│ │ SPENT │ │ UNROLLED│ │ SWEPT │ │
│ │(forfeit│ │(unilat. │ │(ASP re- │ │
│ │ path) │ │ exit) │ │ claimed) │ │
│ └────────┘ └─────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Gotchas & Edge Cases
-
Commitment Chain: VTXOs have a chain of commitment txids back to root. All must be valid for the VTXO to be spendable.
-
Preconfirmed State: A VTXO is preconfirmed until its commitment tx is mined. Don't treat preconfirmed VTXOs as fully secure.
-
Expiration Handling: VTXOs expire! Always check ExpiresAt before attempting to spend. Expired VTXOs need refresh (new round participation).
-
Swept vs Unrolled:
- Swept = ASP reclaimed after expiry
- Unrolled = User performed unilateral exit
Both remove the VTXO from off-chain circulation.
-
Notes are Special: Notes don't require forfeit transactions. Check IsNote() before requiring forfeit.
-
PubKey Format: Server stores pubkey as hex-encoded x-only (32 bytes). Client stores full script.
-
Time Zones: ExpiresAt is Unix timestamp (UTC). Always use time.Unix() for conversions.
-
Concurrent Updates: VTXOs can be updated from multiple sources (rounds, sweeps, settlements). Use version control or proper locking.
-
Client vs Server Model: Field names differ slightly. Client uses time.Time, server uses int64 for timestamps.
-
Recoverable VTXOs: If swept but not spent, user can still recover via unilateral exit (but must wait for CSV delay).
Skill Owner: ark-developer
Repos: arkd, go-sdk