From d92eba4219ff391e8eb74bf059542f22a9ebb04d Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Fri, 30 Jan 2026 20:41:33 +0100 Subject: [PATCH 1/5] deps: add Starlark scripting language dependency Add go.starlark.net for Starlark scripting support. Starlark is a Python-like language designed for configuration and scripting, originally developed for Bazel. This will be used for scriptable autoloop to allow custom swap decision logic. --- go.mod | 7 ++++--- go.sum | 14 ++++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 70b4c49ba..80d62387a 100644 --- a/go.mod +++ b/go.mod @@ -37,8 +37,9 @@ require ( github.com/urfave/cli-docs/v3 v3.1.1-0.20251020101624-bec07369b4f6 github.com/urfave/cli/v3 v3.4.1 go.etcd.io/bbolt v1.4.3 + go.starlark.net v0.0.0-20260102030733-3fee463870c9 golang.org/x/sync v0.18.0 - google.golang.org/grpc v1.64.1 + google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.36.11 gopkg.in/macaroon-bakery.v2 v2.3.0 gopkg.in/macaroon.v2 v2.1.0 @@ -193,8 +194,8 @@ require ( golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect gopkg.in/errgo.v1 v1.0.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index ce24d1956..402b383c8 100644 --- a/go.sum +++ b/go.sum @@ -1386,6 +1386,8 @@ go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpFYYhH08z31NK1HmtTk= +go.starlark.net v0.0.0-20260102030733-3fee463870c9/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= @@ -2033,10 +2035,10 @@ google.golang.org/genproto v0.0.0-20230331144136-dcfb400f0633/go.mod h1:UUQDJDOl google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= -google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf h1:GillM0Ef0pkZPIB+5iO6SDK+4T9pf6TpaYR6ICD5rVE= -google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:OFMYQFHJ4TM3JRlWDZhJbZfra2uqc3WLBZiaaqP4DtU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf h1:liao9UHurZLtiEwBgT9LMOnKYsHze6eA6w1KQCMVN2Q= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= +google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -2078,8 +2080,8 @@ google.golang.org/grpc v1.52.3/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5v google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s= -google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= -google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/grpc/examples v0.0.0-20210424002626-9572fd6faeae/go.mod h1:Ly7ZA/ARzg8fnPU9TyZIxoz33sEUuWX7txiqs8lPTgE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= From 317bea49fc9b64dec453fe4ddc79292cfeb70a37 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Fri, 30 Jan 2026 20:41:40 +0100 Subject: [PATCH 2/5] liquidity/script: add Starlark script evaluation package Add a new package for evaluating Starlark scripts in the autoloop context. This includes: - context.go: Types exposed to scripts (ChannelInfo, PeerInfo, SwapRestrictions, BudgetInfo, InFlightInfo, AutoloopContext, SwapDecision) with Starlark Value interface implementations - builtins.go: Helper functions loop_out() and loop_in() for creating swap decisions in scripts - starlark.go: Evaluator for executing scripts with context data - starlark_test.go: Unit tests for script evaluation Starlark provides Python-like syntax with variables, functions, list comprehensions, and sorting - making complex swap logic readable and maintainable compared to expression languages. --- liquidity/script/builtins.go | 121 +++++++ liquidity/script/context.go | 502 ++++++++++++++++++++++++++++++ liquidity/script/starlark.go | 355 +++++++++++++++++++++ liquidity/script/starlark_test.go | 450 ++++++++++++++++++++++++++ 4 files changed, 1428 insertions(+) create mode 100644 liquidity/script/builtins.go create mode 100644 liquidity/script/context.go create mode 100644 liquidity/script/starlark.go create mode 100644 liquidity/script/starlark_test.go diff --git a/liquidity/script/builtins.go b/liquidity/script/builtins.go new file mode 100644 index 000000000..b8a0bf36e --- /dev/null +++ b/liquidity/script/builtins.go @@ -0,0 +1,121 @@ +package script + +import ( + "fmt" + + "go.starlark.net/starlark" +) + +// loopOutBuiltin creates a loop out swap decision. +// Usage: loop_out(amount, channel_ids) or loop_out(amount, channel_ids, priority) +func loopOutBuiltin(thread *starlark.Thread, fn *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + + var amount starlark.Int + var channelIDs *starlark.List + var priority starlark.Int = starlark.MakeInt(1) + + if err := starlark.UnpackArgs(fn.Name(), args, kwargs, + "amount", &amount, + "channel_ids", &channelIDs, + "priority?", &priority, + ); err != nil { + return nil, err + } + + // Validate amount. + amtVal, ok := amount.Int64() + if !ok { + return nil, fmt.Errorf("amount overflow") + } + if amtVal <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + // Convert channel IDs. + ids := make([]starlark.Value, channelIDs.Len()) + for i := 0; i < channelIDs.Len(); i++ { + ids[i] = channelIDs.Index(i) + } + + priorityVal, ok := priority.Int64() + if !ok { + return nil, fmt.Errorf("priority overflow") + } + + // Create the decision dict. + dict := starlark.NewDict(4) + if err := dict.SetKey(starlark.String("type"), + starlark.String(SwapTypeLoopOut)); err != nil { + return nil, err + } + if err := dict.SetKey(starlark.String("amount"), amount); err != nil { + return nil, err + } + if err := dict.SetKey(starlark.String("channel_ids"), + starlark.NewList(ids)); err != nil { + return nil, err + } + if err := dict.SetKey(starlark.String("priority"), + starlark.MakeInt64(priorityVal)); err != nil { + return nil, err + } + + return dict, nil +} + +// loopInBuiltin creates a loop in swap decision. +// Usage: loop_in(amount, peer_pubkey) or loop_in(amount, peer_pubkey, priority) +func loopInBuiltin(thread *starlark.Thread, fn *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { + + var amount starlark.Int + var peerPubkey starlark.String + var priority starlark.Int = starlark.MakeInt(1) + + if err := starlark.UnpackArgs(fn.Name(), args, kwargs, + "amount", &amount, + "peer_pubkey", &peerPubkey, + "priority?", &priority, + ); err != nil { + return nil, err + } + + // Validate amount. + amtVal, ok := amount.Int64() + if !ok { + return nil, fmt.Errorf("amount overflow") + } + if amtVal <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + // Validate peer pubkey. + if len(peerPubkey) == 0 { + return nil, fmt.Errorf("peer_pubkey cannot be empty") + } + + priorityVal, ok := priority.Int64() + if !ok { + return nil, fmt.Errorf("priority overflow") + } + + // Create the decision dict. + dict := starlark.NewDict(4) + if err := dict.SetKey(starlark.String("type"), + starlark.String(SwapTypeLoopIn)); err != nil { + return nil, err + } + if err := dict.SetKey(starlark.String("amount"), amount); err != nil { + return nil, err + } + if err := dict.SetKey(starlark.String("peer_pubkey"), peerPubkey); err != nil { + return nil, err + } + if err := dict.SetKey(starlark.String("priority"), + starlark.MakeInt64(priorityVal)); err != nil { + return nil, err + } + + return dict, nil +} diff --git a/liquidity/script/context.go b/liquidity/script/context.go new file mode 100644 index 000000000..d1d0073bc --- /dev/null +++ b/liquidity/script/context.go @@ -0,0 +1,502 @@ +package script + +import ( + "time" + + "go.starlark.net/starlark" +) + +// AutoloopContext provides all data available to Starlark scripts for making +// swap decisions. +type AutoloopContext struct { + // Channels provides access to all channel information. + Channels []ChannelInfo + + // Peers provides aggregated per-peer data. + Peers []PeerInfo + + // TotalLocal is the sum of all local balances in satoshis. + TotalLocal int64 + + // TotalRemote is the sum of all remote balances in satoshis. + TotalRemote int64 + + // TotalCapacity is the sum of all channel capacities in satoshis. + TotalCapacity int64 + + // Restrictions contains server-imposed swap limits. + Restrictions SwapRestrictions + + // Budget contains budget information for autoloop. + Budget BudgetInfo + + // InFlight contains information about ongoing swaps. + InFlight InFlightInfo + + // CurrentTime is the current time. + CurrentTime time.Time +} + +// ChannelInfo represents a single channel's state. +type ChannelInfo struct { + // ChannelID is the short channel ID. + ChannelID uint64 + + // PeerPubkey is the hex-encoded public key of the channel peer. + PeerPubkey string + + // Capacity is the total channel capacity in satoshis. + Capacity int64 + + // LocalBalance is the local balance in satoshis. + LocalBalance int64 + + // RemoteBalance is the remote balance in satoshis. + RemoteBalance int64 + + // Active indicates whether the channel is active. + Active bool + + // Private indicates whether the channel is private. + Private bool + + // LocalPercent is the local balance as a percentage of capacity (0-100). + LocalPercent float64 + + // RemotePercent is the remote balance as a percentage of capacity (0-100). + RemotePercent float64 + + // HasLoopOutSwap indicates whether this channel has an ongoing loop out. + HasLoopOutSwap bool + + // HasLoopInSwap indicates whether this channel's peer has an ongoing loop in. + HasLoopInSwap bool + + // RecentlyFailed indicates a swap on this channel failed within backoff. + RecentlyFailed bool + + // FailedAt is the Unix timestamp of the last failure (0 if none). + FailedAt int64 + + // IsCustomChannel indicates whether this is an asset/custom channel. + IsCustomChannel bool +} + +// Starlark interface implementations for ChannelInfo. +var ( + _ starlark.Value = (*ChannelInfo)(nil) + _ starlark.HasAttrs = (*ChannelInfo)(nil) +) + +// String implements starlark.Value. +func (c *ChannelInfo) String() string { + return "" +} + +// Type implements starlark.Value. +func (c *ChannelInfo) Type() string { + return "channel" +} + +// Freeze implements starlark.Value. +func (c *ChannelInfo) Freeze() {} + +// Truth implements starlark.Value. +func (c *ChannelInfo) Truth() starlark.Bool { + return starlark.True +} + +// Hash implements starlark.Value. +func (c *ChannelInfo) Hash() (uint32, error) { + return uint32(c.ChannelID), nil +} + +// Attr implements starlark.HasAttrs. +func (c *ChannelInfo) Attr(name string) (starlark.Value, error) { + switch name { + case "channel_id": + return starlark.MakeInt64(int64(c.ChannelID)), nil + case "peer_pubkey": + return starlark.String(c.PeerPubkey), nil + case "capacity": + return starlark.MakeInt64(c.Capacity), nil + case "local_balance": + return starlark.MakeInt64(c.LocalBalance), nil + case "remote_balance": + return starlark.MakeInt64(c.RemoteBalance), nil + case "active": + return starlark.Bool(c.Active), nil + case "private": + return starlark.Bool(c.Private), nil + case "local_percent": + return starlark.Float(c.LocalPercent), nil + case "remote_percent": + return starlark.Float(c.RemotePercent), nil + case "has_loop_out_swap": + return starlark.Bool(c.HasLoopOutSwap), nil + case "has_loop_in_swap": + return starlark.Bool(c.HasLoopInSwap), nil + case "recently_failed": + return starlark.Bool(c.RecentlyFailed), nil + case "failed_at": + return starlark.MakeInt64(c.FailedAt), nil + case "is_custom_channel": + return starlark.Bool(c.IsCustomChannel), nil + default: + return nil, starlark.NoSuchAttrError(name) + } +} + +// AttrNames implements starlark.HasAttrs. +func (c *ChannelInfo) AttrNames() []string { + return []string{ + "channel_id", "peer_pubkey", "capacity", "local_balance", + "remote_balance", "active", "private", "local_percent", + "remote_percent", "has_loop_out_swap", "has_loop_in_swap", + "recently_failed", "failed_at", "is_custom_channel", + } +} + +// PeerInfo provides aggregated data about a peer across all channels. +type PeerInfo struct { + // Pubkey is the hex-encoded public key of the peer. + Pubkey string + + // TotalCapacity is the sum of all channel capacities with this peer. + TotalCapacity int64 + + // TotalLocal is the sum of all local balances with this peer. + TotalLocal int64 + + // TotalRemote is the sum of all remote balances with this peer. + TotalRemote int64 + + // ChannelCount is the number of channels with this peer. + ChannelCount int + + // ChannelIDs lists all channel IDs with this peer. + ChannelIDs []uint64 + + // LocalPercent is the total local balance as a percentage of capacity. + LocalPercent float64 + + // RemotePercent is the total remote balance as a percentage of capacity. + RemotePercent float64 + + // HasLoopInSwap indicates an ongoing loop in with this peer. + HasLoopInSwap bool +} + +// Starlark interface implementations for PeerInfo. +var ( + _ starlark.Value = (*PeerInfo)(nil) + _ starlark.HasAttrs = (*PeerInfo)(nil) +) + +// String implements starlark.Value. +func (p *PeerInfo) String() string { + return "" +} + +// Type implements starlark.Value. +func (p *PeerInfo) Type() string { + return "peer" +} + +// Freeze implements starlark.Value. +func (p *PeerInfo) Freeze() {} + +// Truth implements starlark.Value. +func (p *PeerInfo) Truth() starlark.Bool { + return starlark.True +} + +// Hash implements starlark.Value. +func (p *PeerInfo) Hash() (uint32, error) { + h := uint32(0) + for _, b := range p.Pubkey { + h = h*31 + uint32(b) + } + return h, nil +} + +// Attr implements starlark.HasAttrs. +func (p *PeerInfo) Attr(name string) (starlark.Value, error) { + switch name { + case "pubkey": + return starlark.String(p.Pubkey), nil + case "total_capacity": + return starlark.MakeInt64(p.TotalCapacity), nil + case "total_local": + return starlark.MakeInt64(p.TotalLocal), nil + case "total_remote": + return starlark.MakeInt64(p.TotalRemote), nil + case "channel_count": + return starlark.MakeInt(p.ChannelCount), nil + case "channel_ids": + ids := make([]starlark.Value, len(p.ChannelIDs)) + for i, id := range p.ChannelIDs { + ids[i] = starlark.MakeInt64(int64(id)) + } + return starlark.NewList(ids), nil + case "local_percent": + return starlark.Float(p.LocalPercent), nil + case "remote_percent": + return starlark.Float(p.RemotePercent), nil + case "has_loop_in_swap": + return starlark.Bool(p.HasLoopInSwap), nil + default: + return nil, starlark.NoSuchAttrError(name) + } +} + +// AttrNames implements starlark.HasAttrs. +func (p *PeerInfo) AttrNames() []string { + return []string{ + "pubkey", "total_capacity", "total_local", "total_remote", + "channel_count", "channel_ids", "local_percent", "remote_percent", + "has_loop_in_swap", + } +} + +// SwapRestrictions contains server-imposed limits on swap amounts. +type SwapRestrictions struct { + // MinLoopOut is the minimum loop out amount in satoshis. + MinLoopOut int64 + + // MaxLoopOut is the maximum loop out amount in satoshis. + MaxLoopOut int64 + + // MinLoopIn is the minimum loop in amount in satoshis. + MinLoopIn int64 + + // MaxLoopIn is the maximum loop in amount in satoshis. + MaxLoopIn int64 +} + +// Starlark interface implementations for SwapRestrictions. +var ( + _ starlark.Value = (*SwapRestrictions)(nil) + _ starlark.HasAttrs = (*SwapRestrictions)(nil) +) + +// String implements starlark.Value. +func (r *SwapRestrictions) String() string { + return "" +} + +// Type implements starlark.Value. +func (r *SwapRestrictions) Type() string { + return "restrictions" +} + +// Freeze implements starlark.Value. +func (r *SwapRestrictions) Freeze() {} + +// Truth implements starlark.Value. +func (r *SwapRestrictions) Truth() starlark.Bool { + return starlark.True +} + +// Hash implements starlark.Value. +func (r *SwapRestrictions) Hash() (uint32, error) { + return 0, nil +} + +// Attr implements starlark.HasAttrs. +func (r *SwapRestrictions) Attr(name string) (starlark.Value, error) { + switch name { + case "min_loop_out": + return starlark.MakeInt64(r.MinLoopOut), nil + case "max_loop_out": + return starlark.MakeInt64(r.MaxLoopOut), nil + case "min_loop_in": + return starlark.MakeInt64(r.MinLoopIn), nil + case "max_loop_in": + return starlark.MakeInt64(r.MaxLoopIn), nil + default: + return nil, starlark.NoSuchAttrError(name) + } +} + +// AttrNames implements starlark.HasAttrs. +func (r *SwapRestrictions) AttrNames() []string { + return []string{"min_loop_out", "max_loop_out", "min_loop_in", "max_loop_in"} +} + +// BudgetInfo contains budget information for autoloop. +type BudgetInfo struct { + // TotalBudget is the total autoloop budget in satoshis. + TotalBudget int64 + + // SpentAmount is the amount already spent in this budget period. + SpentAmount int64 + + // PendingAmount is the amount pending in in-flight swaps. + PendingAmount int64 + + // RemainingAmount is the remaining budget (total - spent - pending). + RemainingAmount int64 +} + +// Starlark interface implementations for BudgetInfo. +var ( + _ starlark.Value = (*BudgetInfo)(nil) + _ starlark.HasAttrs = (*BudgetInfo)(nil) +) + +// String implements starlark.Value. +func (b *BudgetInfo) String() string { + return "" +} + +// Type implements starlark.Value. +func (b *BudgetInfo) Type() string { + return "budget" +} + +// Freeze implements starlark.Value. +func (b *BudgetInfo) Freeze() {} + +// Truth implements starlark.Value. +func (b *BudgetInfo) Truth() starlark.Bool { + return starlark.True +} + +// Hash implements starlark.Value. +func (b *BudgetInfo) Hash() (uint32, error) { + return 0, nil +} + +// Attr implements starlark.HasAttrs. +func (b *BudgetInfo) Attr(name string) (starlark.Value, error) { + switch name { + case "total_budget": + return starlark.MakeInt64(b.TotalBudget), nil + case "spent_amount": + return starlark.MakeInt64(b.SpentAmount), nil + case "pending_amount": + return starlark.MakeInt64(b.PendingAmount), nil + case "remaining_amount": + return starlark.MakeInt64(b.RemainingAmount), nil + default: + return nil, starlark.NoSuchAttrError(name) + } +} + +// AttrNames implements starlark.HasAttrs. +func (b *BudgetInfo) AttrNames() []string { + return []string{ + "total_budget", "spent_amount", "pending_amount", "remaining_amount", + } +} + +// InFlightInfo contains information about ongoing swaps. +type InFlightInfo struct { + // LoopOutCount is the number of in-flight loop out swaps. + LoopOutCount int + + // LoopInCount is the number of in-flight loop in swaps. + LoopInCount int + + // TotalCount is the total number of in-flight swaps. + TotalCount int + + // MaxAllowed is the maximum allowed in-flight swaps (MaxAutoInFlight). + MaxAllowed int + + // LoopOutChannels lists channel IDs with ongoing loop out swaps. + LoopOutChannels []uint64 + + // LoopInPeers lists peer pubkeys with ongoing loop in swaps. + LoopInPeers []string +} + +// Starlark interface implementations for InFlightInfo. +var ( + _ starlark.Value = (*InFlightInfo)(nil) + _ starlark.HasAttrs = (*InFlightInfo)(nil) +) + +// String implements starlark.Value. +func (f *InFlightInfo) String() string { + return "" +} + +// Type implements starlark.Value. +func (f *InFlightInfo) Type() string { + return "in_flight" +} + +// Freeze implements starlark.Value. +func (f *InFlightInfo) Freeze() {} + +// Truth implements starlark.Value. +func (f *InFlightInfo) Truth() starlark.Bool { + return starlark.True +} + +// Hash implements starlark.Value. +func (f *InFlightInfo) Hash() (uint32, error) { + return 0, nil +} + +// Attr implements starlark.HasAttrs. +func (f *InFlightInfo) Attr(name string) (starlark.Value, error) { + switch name { + case "loop_out_count": + return starlark.MakeInt(f.LoopOutCount), nil + case "loop_in_count": + return starlark.MakeInt(f.LoopInCount), nil + case "total_count": + return starlark.MakeInt(f.TotalCount), nil + case "max_allowed": + return starlark.MakeInt(f.MaxAllowed), nil + case "loop_out_channels": + ids := make([]starlark.Value, len(f.LoopOutChannels)) + for i, id := range f.LoopOutChannels { + ids[i] = starlark.MakeInt64(int64(id)) + } + return starlark.NewList(ids), nil + case "loop_in_peers": + peers := make([]starlark.Value, len(f.LoopInPeers)) + for i, p := range f.LoopInPeers { + peers[i] = starlark.String(p) + } + return starlark.NewList(peers), nil + default: + return nil, starlark.NoSuchAttrError(name) + } +} + +// AttrNames implements starlark.HasAttrs. +func (f *InFlightInfo) AttrNames() []string { + return []string{ + "loop_out_count", "loop_in_count", "total_count", "max_allowed", + "loop_out_channels", "loop_in_peers", + } +} + +// SwapDecision represents a swap decision made by a Starlark script. +type SwapDecision struct { + // Type is "loop_out" or "loop_in". + Type string + + // Amount is the swap amount in satoshis. + Amount int64 + + // ChannelIDs specifies outgoing channels for loop out (ignored for loop in). + ChannelIDs []uint64 + + // PeerPubkey specifies the target peer for loop in (ignored for loop out). + PeerPubkey string + + // Priority determines execution order (higher = first). + Priority int +} + +// SwapTypeLoopOut is the type value for loop out swaps. +const SwapTypeLoopOut = "loop_out" + +// SwapTypeLoopIn is the type value for loop in swaps. +const SwapTypeLoopIn = "loop_in" diff --git a/liquidity/script/starlark.go b/liquidity/script/starlark.go new file mode 100644 index 000000000..2364acecd --- /dev/null +++ b/liquidity/script/starlark.go @@ -0,0 +1,355 @@ +package script + +import ( + "fmt" + "sync" + + "go.starlark.net/starlark" + "go.starlark.net/syntax" +) + +// Evaluator handles Starlark script execution and caching. +type Evaluator struct { + // Cache compiled programs by script hash. + cacheMu sync.RWMutex + cache map[string]*starlark.Program +} + +// NewEvaluator creates a new Starlark evaluator. +func NewEvaluator() (*Evaluator, error) { + return &Evaluator{ + cache: make(map[string]*starlark.Program), + }, nil +} + +// Evaluate compiles (if needed) and evaluates a Starlark script with the given +// context. +func (e *Evaluator) Evaluate(script string, + ctx *AutoloopContext) ([]SwapDecision, error) { + + // Build the predeclared globals with context data and builtins. + globals := e.buildGlobals(ctx) + + // Create a new thread for execution. + thread := &starlark.Thread{Name: "autoloop"} + + // Execute the script using the new API with FileOptions. + result, err := starlark.ExecFileOptions( + &syntax.FileOptions{}, + thread, "autoloop.star", script, globals, + ) + if err != nil { + return nil, fmt.Errorf("Starlark execution error: %w", err) + } + + // Extract decisions from the result. + return extractDecisions(result) +} + +// buildGlobals creates the global variables available to scripts. +func (e *Evaluator) buildGlobals(ctx *AutoloopContext) starlark.StringDict { + // Convert channels to Starlark list. + channels := make([]starlark.Value, len(ctx.Channels)) + for i := range ctx.Channels { + channels[i] = &ctx.Channels[i] + } + + // Convert peers to Starlark list. + peers := make([]starlark.Value, len(ctx.Peers)) + for i := range ctx.Peers { + peers[i] = &ctx.Peers[i] + } + + return starlark.StringDict{ + // Context data. + "channels": starlark.NewList(channels), + "peers": starlark.NewList(peers), + "total_local": starlark.MakeInt64(ctx.TotalLocal), + "total_remote": starlark.MakeInt64(ctx.TotalRemote), + "total_capacity": starlark.MakeInt64(ctx.TotalCapacity), + "restrictions": &ctx.Restrictions, + "budget": &ctx.Budget, + "in_flight": &ctx.InFlight, + "current_time": starlark.MakeInt64(ctx.CurrentTime.Unix()), + + // Builtin functions. + "loop_out": starlark.NewBuiltin("loop_out", loopOutBuiltin), + "loop_in": starlark.NewBuiltin("loop_in", loopInBuiltin), + } +} + +// Validate checks if a script compiles without errors. +func (e *Evaluator) Validate(script string) error { + _, err := starlark.ExecFileOptions( + &syntax.FileOptions{}, + &starlark.Thread{Name: "validate"}, + "autoloop.star", + script, + starlark.StringDict{ + // Provide minimal globals for validation. + "channels": starlark.NewList(nil), + "peers": starlark.NewList(nil), + "total_local": starlark.MakeInt(0), + "total_remote": starlark.MakeInt(0), + "total_capacity": starlark.MakeInt(0), + "restrictions": &SwapRestrictions{}, + "budget": &BudgetInfo{}, + "in_flight": &InFlightInfo{}, + "current_time": starlark.MakeInt(0), + "loop_out": starlark.NewBuiltin("loop_out", loopOutBuiltin), + "loop_in": starlark.NewBuiltin("loop_in", loopInBuiltin), + }, + ) + return err +} + +// ClearCache clears the compiled program cache. +func (e *Evaluator) ClearCache() { + e.cacheMu.Lock() + e.cache = make(map[string]*starlark.Program) + e.cacheMu.Unlock() +} + +// extractDecisions extracts SwapDecision values from script results. +func extractDecisions(globals starlark.StringDict) ([]SwapDecision, error) { + // Look for "decisions" in the result. + decisionsVal, ok := globals["decisions"] + if !ok { + return []SwapDecision{}, nil + } + + // Must be a list. + list, ok := decisionsVal.(*starlark.List) + if !ok { + return nil, fmt.Errorf("decisions must be a list, got %s", + decisionsVal.Type()) + } + + decisions := make([]SwapDecision, list.Len()) + for i := 0; i < list.Len(); i++ { + item := list.Index(i) + d, err := valueToDecision(item) + if err != nil { + return nil, fmt.Errorf("decision %d: %w", i, err) + } + decisions[i] = d + } + + return decisions, nil +} + +// valueToDecision converts a Starlark value to a SwapDecision. +func valueToDecision(val starlark.Value) (SwapDecision, error) { + d := SwapDecision{} + + // Must be a dict. + dict, ok := val.(*starlark.Dict) + if !ok { + return d, fmt.Errorf("decision must be a dict, got %s", val.Type()) + } + + // Extract type. + typeVal, found, err := dict.Get(starlark.String("type")) + if err != nil { + return d, err + } + if !found { + return d, fmt.Errorf("missing 'type' field") + } + typeStr, ok := typeVal.(starlark.String) + if !ok { + return d, fmt.Errorf("'type' must be a string") + } + d.Type = string(typeStr) + + // Extract amount. + amountVal, found, err := dict.Get(starlark.String("amount")) + if err != nil { + return d, err + } + if !found { + return d, fmt.Errorf("missing 'amount' field") + } + amountInt, ok := amountVal.(starlark.Int) + if !ok { + return d, fmt.Errorf("'amount' must be an int") + } + amount, ok := amountInt.Int64() + if !ok { + return d, fmt.Errorf("'amount' overflow") + } + d.Amount = amount + + // Extract channel_ids (optional for loop_out). + channelIDsVal, found, err := dict.Get(starlark.String("channel_ids")) + if err != nil { + return d, err + } + if found { + channelList, ok := channelIDsVal.(*starlark.List) + if !ok { + return d, fmt.Errorf("'channel_ids' must be a list") + } + d.ChannelIDs = make([]uint64, channelList.Len()) + for i := 0; i < channelList.Len(); i++ { + idVal := channelList.Index(i) + idInt, ok := idVal.(starlark.Int) + if !ok { + return d, fmt.Errorf("channel_id must be an int") + } + id, ok := idInt.Uint64() + if !ok { + return d, fmt.Errorf("channel_id overflow") + } + d.ChannelIDs[i] = id + } + } + + // Extract peer_pubkey (optional for loop_in). + peerVal, found, err := dict.Get(starlark.String("peer_pubkey")) + if err != nil { + return d, err + } + if found { + peerStr, ok := peerVal.(starlark.String) + if !ok { + return d, fmt.Errorf("'peer_pubkey' must be a string") + } + d.PeerPubkey = string(peerStr) + } + + // Extract priority (optional, default 1). + priorityVal, found, err := dict.Get(starlark.String("priority")) + if err != nil { + return d, err + } + if found { + priorityInt, ok := priorityVal.(starlark.Int) + if !ok { + return d, fmt.Errorf("'priority' must be an int") + } + priority, ok := priorityInt.Int64() + if !ok { + return d, fmt.Errorf("'priority' overflow") + } + d.Priority = int(priority) + } else { + d.Priority = 1 + } + + return d, nil +} + +// ValidateDecisions checks that swap decisions are valid. +func ValidateDecisions(decisions []SwapDecision, ctx *AutoloopContext) error { + for i, d := range decisions { + if err := validateDecision(d, ctx); err != nil { + return fmt.Errorf("decision %d: %w", i, err) + } + } + return nil +} + +func validateDecision(d SwapDecision, ctx *AutoloopContext) error { + // Check type. + if d.Type != SwapTypeLoopOut && d.Type != SwapTypeLoopIn { + return fmt.Errorf("invalid swap type: %s", d.Type) + } + + // Check amount. + if d.Amount <= 0 { + return fmt.Errorf("amount must be positive: %d", d.Amount) + } + + // Type-specific validation. + switch d.Type { + case SwapTypeLoopOut: + // Check amount against restrictions. + if d.Amount < ctx.Restrictions.MinLoopOut { + return fmt.Errorf( + "amount %d below minimum %d", + d.Amount, ctx.Restrictions.MinLoopOut, + ) + } + if d.Amount > ctx.Restrictions.MaxLoopOut { + return fmt.Errorf( + "amount %d above maximum %d", + d.Amount, ctx.Restrictions.MaxLoopOut, + ) + } + // Check channel IDs exist. + if len(d.ChannelIDs) == 0 { + return fmt.Errorf("loop out requires at least one channel ID") + } + for _, chanID := range d.ChannelIDs { + if !channelExists(chanID, ctx.Channels) { + return fmt.Errorf("channel %d not found", chanID) + } + } + + case SwapTypeLoopIn: + // Check amount against restrictions. + if d.Amount < ctx.Restrictions.MinLoopIn { + return fmt.Errorf( + "amount %d below minimum %d", + d.Amount, ctx.Restrictions.MinLoopIn, + ) + } + if d.Amount > ctx.Restrictions.MaxLoopIn { + return fmt.Errorf( + "amount %d above maximum %d", + d.Amount, ctx.Restrictions.MaxLoopIn, + ) + } + // Check peer pubkey. + if d.PeerPubkey == "" { + return fmt.Errorf("loop in requires peer pubkey") + } + if !peerExists(d.PeerPubkey, ctx.Peers) { + return fmt.Errorf("peer %s not found", d.PeerPubkey) + } + } + + return nil +} + +func channelExists(chanID uint64, channels []ChannelInfo) bool { + for _, c := range channels { + if c.ChannelID == chanID { + return true + } + } + return false +} + +func peerExists(pubkey string, peers []PeerInfo) bool { + for _, p := range peers { + if p.Pubkey == pubkey { + return true + } + } + return false +} + +// SortByPriority sorts swap decisions by priority (highest first). +func SortByPriority(decisions []SwapDecision) { + // Simple insertion sort since we expect small lists. + for i := 1; i < len(decisions); i++ { + key := decisions[i] + j := i - 1 + for j >= 0 && decisions[j].Priority < key.Priority { + decisions[j+1] = decisions[j] + j-- + } + decisions[j+1] = key + } +} + +// ValidateScript is a package-level function for validating Starlark scripts. +func ValidateScript(script string) error { + eval, err := NewEvaluator() + if err != nil { + return err + } + return eval.Validate(script) +} diff --git a/liquidity/script/starlark_test.go b/liquidity/script/starlark_test.go new file mode 100644 index 000000000..c70e33da1 --- /dev/null +++ b/liquidity/script/starlark_test.go @@ -0,0 +1,450 @@ +package script + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestNewEvaluator tests that an evaluator can be created. +func TestNewEvaluator(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + require.NotNil(t, eval) +} + +// TestEvalEmptyList tests evaluating a script that returns an empty list. +func TestEvalEmptyList(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := `decisions = []` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{}, + Peers: []PeerInfo{}, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 0) +} + +// TestEvalWithChannelData tests evaluating a script with channel data. +func TestEvalWithChannelData(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + // Script that returns a loop out if total local > 100000. + // Note: Starlark requires if statements to be in functions. + script := ` +def autoloop(): + if total_local > 100000: + amount = min(total_local - 100000, restrictions.max_loop_out) + return [loop_out(amount, [channels[0].channel_id])] + return [] + +decisions = autoloop() +` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + { + ChannelID: 123456, + LocalBalance: 150000, + Active: true, + }, + }, + TotalLocal: 150000, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) + require.Equal(t, SwapTypeLoopOut, decisions[0].Type) + require.Equal(t, int64(50000), decisions[0].Amount) + require.Equal(t, []uint64{123456}, decisions[0].ChannelIDs) +} + +// TestEvalNoSwapNeeded tests when no swap is needed. +func TestEvalNoSwapNeeded(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := ` +def autoloop(): + if total_local > 100000: + amount = min(total_local - 100000, restrictions.max_loop_out) + return [loop_out(amount, [channels[0].channel_id])] + return [] + +decisions = autoloop() +` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + { + ChannelID: 123456, + LocalBalance: 50000, + Active: true, + }, + }, + TotalLocal: 50000, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 0) +} + +// TestLoopOutBuiltin tests the loop_out helper function. +func TestLoopOutBuiltin(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := `decisions = [loop_out(50000, [123])]` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + {ChannelID: 123}, + }, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) + require.Equal(t, SwapTypeLoopOut, decisions[0].Type) + require.Equal(t, int64(50000), decisions[0].Amount) + require.Equal(t, []uint64{123}, decisions[0].ChannelIDs) +} + +// TestLoopInBuiltin tests the loop_in helper function. +func TestLoopInBuiltin(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := `decisions = [loop_in(50000, "02abc123")]` + + ctx := &AutoloopContext{ + Peers: []PeerInfo{ + {Pubkey: "02abc123"}, + }, + Restrictions: SwapRestrictions{ + MinLoopIn: 10000, + MaxLoopIn: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) + require.Equal(t, SwapTypeLoopIn, decisions[0].Type) + require.Equal(t, int64(50000), decisions[0].Amount) + require.Equal(t, "02abc123", decisions[0].PeerPubkey) +} + +// TestChannelFiltering tests filtering channels with list comprehension. +func TestChannelFiltering(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := ` +def autoloop(): + eligible = [c for c in channels if c.active and not c.is_custom_channel] + if len(eligible) > 0: + return [loop_out(50000, [eligible[0].channel_id])] + return [] + +decisions = autoloop() +` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + { + ChannelID: 1, + Active: false, + IsCustomChannel: false, + }, + { + ChannelID: 2, + Active: true, + IsCustomChannel: true, + }, + { + ChannelID: 3, + Active: true, + IsCustomChannel: false, + }, + }, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) + require.Equal(t, []uint64{3}, decisions[0].ChannelIDs) +} + +// TestChannelSorting tests sorting channels by local balance. +func TestChannelSorting(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := ` +# Sort by local_balance descending +eligible = sorted(channels, key=lambda c: -c.local_balance) +decisions = [loop_out(50000, [eligible[0].channel_id])] +` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + {ChannelID: 1, LocalBalance: 100000}, + {ChannelID: 2, LocalBalance: 300000}, + {ChannelID: 3, LocalBalance: 200000}, + }, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) + // Should pick channel 2 (highest local balance). + require.Equal(t, []uint64{2}, decisions[0].ChannelIDs) +} + +// TestValidateDecisions tests decision validation. +func TestValidateDecisions(t *testing.T) { + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + {ChannelID: 123}, + }, + Peers: []PeerInfo{ + {Pubkey: "02abc"}, + }, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + MinLoopIn: 10000, + MaxLoopIn: 1000000, + }, + } + + tests := []struct { + name string + decision SwapDecision + wantError bool + }{ + { + name: "valid loop out", + decision: SwapDecision{ + Type: SwapTypeLoopOut, + Amount: 50000, + ChannelIDs: []uint64{123}, + }, + wantError: false, + }, + { + name: "valid loop in", + decision: SwapDecision{ + Type: SwapTypeLoopIn, + Amount: 50000, + PeerPubkey: "02abc", + }, + wantError: false, + }, + { + name: "invalid type", + decision: SwapDecision{ + Type: "invalid", + Amount: 50000, + }, + wantError: true, + }, + { + name: "amount too low", + decision: SwapDecision{ + Type: SwapTypeLoopOut, + Amount: 100, + ChannelIDs: []uint64{123}, + }, + wantError: true, + }, + { + name: "channel not found", + decision: SwapDecision{ + Type: SwapTypeLoopOut, + Amount: 50000, + ChannelIDs: []uint64{999}, + }, + wantError: true, + }, + { + name: "peer not found", + decision: SwapDecision{ + Type: SwapTypeLoopIn, + Amount: 50000, + PeerPubkey: "unknown", + }, + wantError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateDecisions([]SwapDecision{tc.decision}, ctx) + if tc.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestSortByPriority tests priority sorting. +func TestSortByPriority(t *testing.T) { + decisions := []SwapDecision{ + {Type: SwapTypeLoopOut, Priority: 1}, + {Type: SwapTypeLoopIn, Priority: 3}, + {Type: SwapTypeLoopOut, Priority: 2}, + } + + SortByPriority(decisions) + + require.Equal(t, 3, decisions[0].Priority) + require.Equal(t, 2, decisions[1].Priority) + require.Equal(t, 1, decisions[2].Priority) +} + +// TestDefFunction tests defining and calling a function. +func TestDefFunction(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := ` +def autoloop(): + if total_local > 100000: + return [loop_out(total_local - 100000, [channels[0].channel_id])] + return [] + +decisions = autoloop() +` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + {ChannelID: 123, LocalBalance: 150000}, + }, + TotalLocal: 150000, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) + require.Equal(t, int64(50000), decisions[0].Amount) +} + +// TestAccessRestrictions tests accessing restrictions fields. +func TestAccessRestrictions(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + // Note: Starlark doesn't support chained comparisons like Python. + // Use explicit and instead. + script := ` +def autoloop(): + if restrictions.min_loop_out <= 50000 and 50000 <= restrictions.max_loop_out: + return [loop_out(50000, [channels[0].channel_id])] + return [] + +decisions = autoloop() +` + + ctx := &AutoloopContext{ + Channels: []ChannelInfo{ + {ChannelID: 123}, + }, + Restrictions: SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 1000000, + }, + CurrentTime: time.Now(), + } + + decisions, err := eval.Evaluate(script, ctx) + require.NoError(t, err) + require.Len(t, decisions, 1) +} + +// TestInvalidScript tests that invalid scripts return errors. +func TestInvalidScript(t *testing.T) { + eval, err := NewEvaluator() + require.NoError(t, err) + + script := `this is not valid starlark` + + ctx := &AutoloopContext{CurrentTime: time.Now()} + _, err = eval.Evaluate(script, ctx) + require.Error(t, err) +} + +// TestValidateScript tests script validation. +func TestValidateScript(t *testing.T) { + tests := []struct { + name string + script string + wantError bool + }{ + { + name: "valid script", + script: `decisions = []`, + wantError: false, + }, + { + name: "invalid syntax", + script: `this is not valid`, + wantError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateScript(tc.script) + if tc.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} From c5a63c12f88fa1c44a4ff5f502a8683ae1090a2d Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Fri, 30 Jan 2026 20:41:49 +0100 Subject: [PATCH 3/5] liquidity: integrate scriptable autoloop mode Add scriptable autoloop as a new mode alongside easy autoloop and threshold rules. This allows users to provide custom Starlark scripts that have full control over swap decisions. Key changes: - scriptable.go: ScriptableManager that evaluates user scripts on each tick, builds context from current channel/peer state, and converts script decisions to swap suggestions - parameters.go: Add ScriptableAutoloop, ScriptableScript, and ScriptableTickInterval parameters with validation to ensure scriptable mode is mutually exclusive with easy autoloop and rules - liquidity.go: Wire up scriptable manager to the autoloop system - script_equivalence_test.go: Tests verifying Starlark scripts can replicate easy-autoloop behavior plus advanced scenarios The scriptable mode gives operators fine-grained control with readable Python-like syntax: def autoloop(): eligible = [c for c in channels if c.active] eligible = sorted(eligible, key=lambda c: -c.local_balance) if eligible and total_local > 100000: return [loop_out(50000, [eligible[0].channel_id])] return [] decisions = autoloop() --- liquidity/liquidity.go | 10 +- liquidity/parameters.go | 51 ++- liquidity/script_equivalence_test.go | 551 +++++++++++++++++++++++++++ liquidity/scriptable.go | 420 ++++++++++++++++++++ 4 files changed, 1025 insertions(+), 7 deletions(-) create mode 100644 liquidity/script_equivalence_test.go create mode 100644 liquidity/scriptable.go diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index ca210c3da..eefad196d 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -293,7 +293,15 @@ func (m *Manager) Run(ctx context.Context) error { for { select { case <-m.cfg.AutoloopTicker.Ticks(): - if m.params.EasyAutoloop { + // Check which autoloop mode to use. + // Priority: scriptable > easy > threshold rules. + if m.params.ScriptableAutoloop { + err := m.scriptableAutoLoop(ctx) + if err != nil { + log.Errorf("scriptable autoloop "+ + "failed: %v", err) + } + } else if m.params.EasyAutoloop { err := m.easyAutoLoop(ctx) if err != nil { log.Errorf("easy autoloop failed: %v", diff --git a/liquidity/parameters.go b/liquidity/parameters.go index 901959c81..18a8c82d1 100644 --- a/liquidity/parameters.go +++ b/liquidity/parameters.go @@ -128,6 +128,18 @@ type Parameters struct { // swaps. If set to true, the deadline is set to immediate publication. // If set to false, the deadline is set to 30 minutes. FastSwapPublication bool + + // ScriptableAutoloop enables CEL-based scriptable autoloop mode. + // This mode is mutually exclusive with EasyAutoloop and threshold rules. + ScriptableAutoloop bool + + // ScriptableScript is the CEL expression to evaluate on each tick. + // Required when ScriptableAutoloop is true. + ScriptableScript string + + // ScriptableTickInterval overrides the default tick interval for + // scriptable mode. Zero means use DefaultAutoloopTicker. + ScriptableTickInterval time.Duration } // AssetParams define the asset specific autoloop parameters. @@ -195,6 +207,27 @@ func (p Parameters) haveRules() bool { func (p Parameters) validate(minConfs int32, openChans []lndclient.ChannelInfo, server *Restrictions) error { + // Check scriptable autoloop constraints first. + if p.ScriptableAutoloop { + // Scriptable autoloop is mutually exclusive with easy autoloop. + if p.EasyAutoloop { + return errors.New("scriptable_autoloop and easy_autoloop " + + "are mutually exclusive") + } + + // Scriptable autoloop is mutually exclusive with rules. + if len(p.ChannelRules) > 0 || len(p.PeerRules) > 0 { + return errors.New("scriptable_autoloop cannot be used " + + "with channel/peer rules") + } + + // Script is required when scriptable autoloop is enabled. + if p.ScriptableScript == "" { + return errors.New("scriptable_script is required when " + + "scriptable_autoloop is enabled") + } + } + // First, we check that the rules on a per peer and per channel do not // overlap, since this could lead to contractions. for _, channel := range openChans { @@ -475,8 +508,11 @@ func RpcToParameters(req *clientrpc.LiquidityParameters) (*Parameters, EasyAutoloopTarget: btcutil.Amount( req.EasyAutoloopLocalTargetSat, ), - AssetAutoloopParams: easyAssetParams, - FastSwapPublication: req.FastSwapPublication, + AssetAutoloopParams: easyAssetParams, + FastSwapPublication: req.FastSwapPublication, + ScriptableAutoloop: req.ScriptableAutoloop, + ScriptableScript: req.ScriptableScript, + ScriptableTickInterval: time.Duration(req.ScriptableTickIntervalSec) * time.Second, } if req.AutoloopBudgetRefreshPeriodSec != 0 { @@ -617,10 +653,13 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, HtlcConfTarget: cfg.HtlcConfTarget, EasyAutoloop: cfg.EasyAutoloop, EasyAutoloopLocalTargetSat: uint64(cfg.EasyAutoloopTarget), - Account: cfg.Account, - AccountAddrType: addrType, - EasyAssetParams: easyAssetMap, - FastSwapPublication: cfg.FastSwapPublication, + Account: cfg.Account, + AccountAddrType: addrType, + EasyAssetParams: easyAssetMap, + FastSwapPublication: cfg.FastSwapPublication, + ScriptableAutoloop: cfg.ScriptableAutoloop, + ScriptableScript: cfg.ScriptableScript, + ScriptableTickIntervalSec: uint64(cfg.ScriptableTickInterval / time.Second), } // Set excluded peers for easy autoloop. rpcCfg.EasyAutoloopExcludedPeers = make( diff --git a/liquidity/script_equivalence_test.go b/liquidity/script_equivalence_test.go new file mode 100644 index 000000000..449e5337d --- /dev/null +++ b/liquidity/script_equivalence_test.go @@ -0,0 +1,551 @@ +package liquidity + +import ( + "context" + "encoding/hex" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/liquidity/script" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swap" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" + "github.com/stretchr/testify/require" +) + +// EasyAutoloopStarlarkScript is a Starlark script that replicates easy-autoloop. +// Target: 100000 sats (embedded in script) +const EasyAutoloopStarlarkScript = ` +# Easy autoloop equivalent script in Starlark +# Target: 100000 sats + +def autoloop(): + target = 100000 + + # Check if we're at or below target - no swap needed + if total_local <= target: + return [] + + # Calculate amount to loop out (clamped to max) + amount = min(total_local - target, restrictions.max_loop_out) + if amount < restrictions.min_loop_out: + return [] + + # Filter eligible channels + eligible = [ + c for c in channels + if c.active + and not c.is_custom_channel + and not c.has_loop_out_swap + and not c.recently_failed + and c.local_balance >= restrictions.min_loop_out + ] + + if len(eligible) == 0: + return [] + + # Sort by local balance descending, pick best + eligible = sorted(eligible, key=lambda c: -c.local_balance) + best = eligible[0] + + # Create swap decision + swap_amount = min(best.local_balance, amount) + return [loop_out(swap_amount, [best.channel_id])] + +# Execute and return decisions +decisions = autoloop() +` + +// TestStarlarkEquivalentToEasyAutoloop validates that a Starlark script can +// produce the same swap decisions as the easy autoloop mode. +func TestStarlarkEquivalentToEasyAutoloop(t *testing.T) { + testCases := []struct { + name string + channels []lndclient.ChannelInfo + target btcutil.Amount + minSwap btcutil.Amount + maxSwap btcutil.Amount + expectSwap bool + expectAmount btcutil.Amount + expectChanID uint64 + }{ + { + name: "total below target - no swap", + channels: []lndclient.ChannelInfo{ + { + ChannelID: 1, + PubKeyBytes: route.Vertex{1}, + Capacity: 100000, + LocalBalance: 40000, + Active: true, + }, + { + ChannelID: 2, + PubKeyBytes: route.Vertex{2}, + Capacity: 100000, + LocalBalance: 50000, + Active: true, + }, + }, + target: 100000, + minSwap: 10000, + maxSwap: 1000000, + expectSwap: false, + }, + { + name: "total above target - swap from highest balance channel", + channels: []lndclient.ChannelInfo{ + { + ChannelID: 1, + PubKeyBytes: route.Vertex{1}, + Capacity: 200000, + LocalBalance: 80000, + Active: true, + }, + { + ChannelID: 2, + PubKeyBytes: route.Vertex{2}, + Capacity: 200000, + LocalBalance: 120000, + Active: true, + }, + }, + target: 100000, + minSwap: 10000, + maxSwap: 1000000, + expectSwap: true, + expectAmount: 100000, // min(120000, 200000-100000) = 100000 + expectChanID: 2, // Channel 2 has highest local balance + }, + { + name: "inactive channel skipped", + channels: []lndclient.ChannelInfo{ + { + ChannelID: 1, + PubKeyBytes: route.Vertex{1}, + Capacity: 200000, + LocalBalance: 180000, + Active: false, // Inactive + }, + { + ChannelID: 2, + PubKeyBytes: route.Vertex{2}, + Capacity: 200000, + LocalBalance: 120000, + Active: true, + }, + }, + target: 100000, + minSwap: 10000, + maxSwap: 1000000, + expectSwap: true, + expectAmount: 120000, // min(120000, 300000-100000) = 120000 + expectChanID: 2, // Channel 1 is inactive, pick 2 + }, + { + name: "amount below minimum - no swap", + channels: []lndclient.ChannelInfo{ + { + ChannelID: 1, + PubKeyBytes: route.Vertex{1}, + Capacity: 200000, + LocalBalance: 105000, + Active: true, + }, + }, + target: 100000, + minSwap: 10000, + maxSwap: 1000000, + expectSwap: false, // 105000 - 100000 = 5000 < minSwap + }, + { + name: "multiple channels - picks highest balance", + channels: []lndclient.ChannelInfo{ + { + ChannelID: 1, + PubKeyBytes: route.Vertex{1}, + Capacity: 300000, + LocalBalance: 100000, + Active: true, + }, + { + ChannelID: 2, + PubKeyBytes: route.Vertex{2}, + Capacity: 300000, + LocalBalance: 250000, + Active: true, + }, + { + ChannelID: 3, + PubKeyBytes: route.Vertex{3}, + Capacity: 300000, + LocalBalance: 150000, + Active: true, + }, + }, + target: 100000, + minSwap: 10000, + maxSwap: 1000000, + expectSwap: true, + expectAmount: 250000, // min(250000, 500000-100000) = 250000 + expectChanID: 2, // Channel 2 has highest balance + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Build script context. + scriptCtx := buildTestScriptContext( + tc.channels, tc.minSwap, tc.maxSwap, + ) + + // Evaluate Starlark script. + eval, err := script.NewEvaluator() + require.NoError(t, err) + + decisions, err := eval.Evaluate( + EasyAutoloopStarlarkScript, scriptCtx, + ) + require.NoError(t, err) + + if !tc.expectSwap { + require.Len(t, decisions, 0, + "expected no swap but got %d decisions", + len(decisions)) + return + } + + require.Len(t, decisions, 1, + "expected 1 swap but got %d decisions", + len(decisions)) + + d := decisions[0] + require.Equal(t, script.SwapTypeLoopOut, d.Type) + require.Equal(t, int64(tc.expectAmount), d.Amount, + "amount mismatch") + require.Len(t, d.ChannelIDs, 1) + require.Equal(t, tc.expectChanID, d.ChannelIDs[0], + "channel ID mismatch") + }) + } +} + +// buildTestScriptContext creates a script context for testing. +func buildTestScriptContext(channels []lndclient.ChannelInfo, + minSwap, maxSwap btcutil.Amount) *script.AutoloopContext { + + var totalLocal, totalRemote, totalCapacity int64 + channelInfos := make([]script.ChannelInfo, len(channels)) + + for i, ch := range channels { + totalLocal += int64(ch.LocalBalance) + totalRemote += int64(ch.RemoteBalance) + totalCapacity += int64(ch.Capacity) + + var localPercent, remotePercent float64 + if ch.Capacity > 0 { + localPercent = float64(ch.LocalBalance) / float64(ch.Capacity) * 100 + remotePercent = float64(ch.RemoteBalance) / float64(ch.Capacity) * 100 + } + + channelInfos[i] = script.ChannelInfo{ + ChannelID: ch.ChannelID, + PeerPubkey: hex.EncodeToString(ch.PubKeyBytes[:]), + Capacity: int64(ch.Capacity), + LocalBalance: int64(ch.LocalBalance), + RemoteBalance: int64(ch.RemoteBalance), + Active: ch.Active, + Private: ch.Private, + LocalPercent: localPercent, + RemotePercent: remotePercent, + HasLoopOutSwap: false, + HasLoopInSwap: false, + RecentlyFailed: false, + IsCustomChannel: false, + } + } + + return &script.AutoloopContext{ + Channels: channelInfos, + Peers: []script.PeerInfo{}, + TotalLocal: totalLocal, + TotalRemote: totalRemote, + TotalCapacity: totalCapacity, + Restrictions: script.SwapRestrictions{ + MinLoopOut: int64(minSwap), + MaxLoopOut: int64(maxSwap), + MinLoopIn: int64(minSwap), + MaxLoopIn: int64(maxSwap), + }, + Budget: script.BudgetInfo{ + TotalBudget: 1000000, + RemainingAmount: 1000000, + }, + InFlight: script.InFlightInfo{ + MaxAllowed: 5, + }, + CurrentTime: time.Now(), + } +} + +// TestScriptableAutoloopIntegration tests the full scriptable autoloop flow. +func TestScriptableAutoloopIntegration(t *testing.T) { + ctx := context.Background() + testClock := clock.NewTestClock(time.Now()) + + // Create test channels. + peer1Bytes := [33]byte{2, 1} + peer2Bytes := [33]byte{2, 2} + + testChannels := []lndclient.ChannelInfo{ + { + ChannelID: 123, + PubKeyBytes: peer1Bytes, + Capacity: 500000, + LocalBalance: 300000, // Higher balance + Active: true, + }, + { + ChannelID: 456, + PubKeyBytes: peer2Bytes, + Capacity: 500000, + LocalBalance: 200000, + Active: true, + }, + } + + // Create mock config. + lnd := test.NewMockLnd() + lnd.Channels = testChannels + + cfg := &Config{ + Lnd: &lnd.LndServices, + Clock: testClock, + ListLoopOut: func(context.Context) ([]*loopdb.LoopOut, error) { + return nil, nil + }, + ListLoopIn: func(context.Context) ([]*loopdb.LoopIn, error) { + return nil, nil + }, + Restrictions: func(ctx context.Context, swapType swap.Type, + initiator string) (*Restrictions, error) { + return &Restrictions{ + Minimum: 10000, + Maximum: 1000000, + }, nil + }, + LoopOutQuote: func(ctx context.Context, + req *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) { + return &loop.LoopOutQuote{ + SwapFee: 100, + PrepayAmount: 1000, + MinerFee: 500, + }, nil + }, + } + + manager := NewManager(cfg) + + // Set scriptable autoloop parameters. + manager.params.ScriptableAutoloop = true + manager.params.ScriptableScript = EasyAutoloopStarlarkScript + manager.params.AutoFeeBudget = 1000000 + manager.params.MaxAutoInFlight = 5 + + // Build script context (this is what scriptableAutoLoop does internally). + scriptCtx, err := manager.buildScriptContext(ctx) + require.NoError(t, err) + + // Verify context was built correctly. + require.Equal(t, int64(500000), scriptCtx.TotalLocal) + require.Len(t, scriptCtx.Channels, 2) + require.Equal(t, int64(10000), scriptCtx.Restrictions.MinLoopOut) + require.Equal(t, int64(1000000), scriptCtx.Restrictions.MaxLoopOut) + + // Evaluate the Starlark script. + eval, err := script.NewEvaluator() + require.NoError(t, err) + + decisions, err := eval.Evaluate(EasyAutoloopStarlarkScript, scriptCtx) + require.NoError(t, err) + + // Should have one swap decision since total local (500000) > target (100000). + require.Len(t, decisions, 1) + require.Equal(t, script.SwapTypeLoopOut, decisions[0].Type) + // Amount should be min(300000, 500000-100000) = 300000. + require.Equal(t, int64(300000), decisions[0].Amount) + // Should pick channel 123 (highest local balance). + require.Equal(t, []uint64{123}, decisions[0].ChannelIDs) +} + +// TestScriptableParameterValidation tests parameter validation for +// scriptable mode. +func TestScriptableParameterValidation(t *testing.T) { + testCases := []struct { + name string + params Parameters + expectError string + }{ + { + name: "scriptable without script", + params: Parameters{ + ScriptableAutoloop: true, + ScriptableScript: "", + }, + expectError: "scriptable_script is required", + }, + { + name: "scriptable with easy autoloop", + params: Parameters{ + ScriptableAutoloop: true, + ScriptableScript: "decisions = []", + EasyAutoloop: true, + }, + expectError: "mutually exclusive", + }, + { + name: "scriptable with channel rules", + params: Parameters{ + ScriptableAutoloop: true, + ScriptableScript: "decisions = []", + ChannelRules: map[lnwire.ShortChannelID]*SwapRule{ + lnwire.NewShortChanIDFromInt(123): { + ThresholdRule: &ThresholdRule{}, + }, + }, + }, + expectError: "cannot be used with channel/peer rules", + }, + { + name: "valid scriptable params", + params: Parameters{ + ScriptableAutoloop: true, + ScriptableScript: "decisions = []", + SweepConfTarget: 6, + HtlcConfTarget: 6, + MaxAutoInFlight: 1, + FeeLimit: defaultFeePortion(), + }, + expectError: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.params.validate(1, nil, &Restrictions{}) + if tc.expectError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectError) + } else { + require.NoError(t, err) + } + }) + } +} + +// TestStarlarkAdvancedScript tests more advanced Starlark features. +func TestStarlarkAdvancedScript(t *testing.T) { + // Test a script that uses multiple functions and loop in. + advancedScript := ` +def get_low_inbound_peers(): + """Find peers with less than 30% remote balance.""" + return [ + p for p in peers + if p.remote_percent < 30 and not p.has_loop_in_swap + ] + +def autoloop(): + # First, handle loop outs for high local balance + loop_out_decisions = [] + high_local = [c for c in channels if c.local_percent > 70 and c.active] + for c in high_local[:1]: # Limit to 1 + amt = min(c.local_balance // 2, restrictions.max_loop_out) + if amt >= restrictions.min_loop_out: + loop_out_decisions.append(loop_out(amt, [c.channel_id])) + + # Then, handle loop ins for low inbound peers + loop_in_decisions = [] + low_inbound = get_low_inbound_peers() + for p in low_inbound[:1]: # Limit to 1 + amt = min(p.total_capacity // 4, restrictions.max_loop_in) + if amt >= restrictions.min_loop_in: + loop_in_decisions.append(loop_in(amt, p.pubkey)) + + return loop_out_decisions + loop_in_decisions + +decisions = autoloop() +` + + channelInfos := []script.ChannelInfo{ + { + ChannelID: 1, + PeerPubkey: "02aabbcc", + Capacity: 1000000, + LocalBalance: 800000, // 80% local + RemoteBalance: 200000, + Active: true, + LocalPercent: 80, + RemotePercent: 20, + }, + } + + peerInfos := []script.PeerInfo{ + { + Pubkey: "02aabbcc", + TotalCapacity: 1000000, + TotalLocal: 800000, + TotalRemote: 200000, + ChannelCount: 1, + ChannelIDs: []uint64{1}, + LocalPercent: 80, + RemotePercent: 20, + HasLoopInSwap: false, + }, + } + + ctx := &script.AutoloopContext{ + Channels: channelInfos, + Peers: peerInfos, + TotalLocal: 800000, + TotalRemote: 200000, + TotalCapacity: 1000000, + Restrictions: script.SwapRestrictions{ + MinLoopOut: 10000, + MaxLoopOut: 500000, + MinLoopIn: 10000, + MaxLoopIn: 500000, + }, + Budget: script.BudgetInfo{ + TotalBudget: 1000000, + RemainingAmount: 1000000, + }, + InFlight: script.InFlightInfo{ + MaxAllowed: 5, + }, + CurrentTime: time.Now(), + } + + eval, err := script.NewEvaluator() + require.NoError(t, err) + + decisions, err := eval.Evaluate(advancedScript, ctx) + require.NoError(t, err) + + // Should have 1 loop out (high local balance) and 1 loop in (low remote). + require.Len(t, decisions, 2) + + // First decision should be loop out. + require.Equal(t, script.SwapTypeLoopOut, decisions[0].Type) + require.Equal(t, int64(400000), decisions[0].Amount) // 800000 / 2 + + // Second decision should be loop in. + require.Equal(t, script.SwapTypeLoopIn, decisions[1].Type) + require.Equal(t, int64(250000), decisions[1].Amount) // 1000000 / 4 + require.Equal(t, "02aabbcc", decisions[1].PeerPubkey) +} diff --git a/liquidity/scriptable.go b/liquidity/scriptable.go new file mode 100644 index 000000000..e01313770 --- /dev/null +++ b/liquidity/scriptable.go @@ -0,0 +1,420 @@ +package liquidity + +import ( + "context" + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/liquidity/script" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/routing/route" +) + +// scriptEvaluator is a cached Starlark evaluator for scriptable autoloop. +var scriptEvaluator *script.Evaluator + +// getScriptEvaluator returns a cached Starlark evaluator, creating one if +// needed. +func getScriptEvaluator() (*script.Evaluator, error) { + if scriptEvaluator != nil { + return scriptEvaluator, nil + } + eval, err := script.NewEvaluator() + if err != nil { + return nil, err + } + scriptEvaluator = eval + return scriptEvaluator, nil +} + +// scriptableAutoLoop executes Starlark-based autoloop logic. +func (m *Manager) scriptableAutoLoop(ctx context.Context) error { + // Get the script from parameters. + scriptCode := m.params.ScriptableScript + if scriptCode == "" { + return fmt.Errorf("scriptable autoloop enabled but no script configured") + } + + // Get the Starlark evaluator. + eval, err := getScriptEvaluator() + if err != nil { + return fmt.Errorf("failed to get script evaluator: %w", err) + } + + // Build the autoloop context for Starlark. + scriptCtx, err := m.buildScriptContext(ctx) + if err != nil { + return fmt.Errorf("failed to build script context: %w", err) + } + + // Evaluate the Starlark script. + log.Debugf("scriptable autoloop: evaluating Starlark script") + decisions, err := eval.Evaluate(scriptCode, scriptCtx) + if err != nil { + return fmt.Errorf("Starlark evaluation failed: %w", err) + } + + if len(decisions) == 0 { + log.Debugf("scriptable autoloop: no swap decisions") + return nil + } + + log.Infof("scriptable autoloop: Starlark script returned %d decisions", + len(decisions)) + + // Sort decisions by priority. + script.SortByPriority(decisions) + + // Validate decisions. + if err := script.ValidateDecisions(decisions, scriptCtx); err != nil { + return fmt.Errorf("invalid swap decisions: %w", err) + } + + // Dispatch swaps based on decisions. + for _, d := range decisions { + // Check if we have room for more in-flight swaps. + if scriptCtx.InFlight.TotalCount >= scriptCtx.InFlight.MaxAllowed { + log.Debugf("scriptable autoloop: max in-flight reached, "+ + "skipping remaining %d decisions", len(decisions)) + break + } + + switch d.Type { + case script.SwapTypeLoopOut: + err := m.dispatchScriptableLoopOut(ctx, d, scriptCtx) + if err != nil { + log.Errorf("scriptable autoloop: loop out "+ + "dispatch failed: %v", err) + continue + } + scriptCtx.InFlight.TotalCount++ + + case script.SwapTypeLoopIn: + err := m.dispatchScriptableLoopIn(ctx, d) + if err != nil { + log.Errorf("scriptable autoloop: loop in "+ + "dispatch failed: %v", err) + continue + } + scriptCtx.InFlight.TotalCount++ + } + } + + return nil +} + +// buildScriptContext creates the AutoloopContext for Starlark script +// evaluation. +func (m *Manager) buildScriptContext( + ctx context.Context) (*script.AutoloopContext, error) { + + // Get existing swaps. + loopOut, err := m.cfg.ListLoopOut(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list loop outs: %w", err) + } + + loopIn, err := m.cfg.ListLoopIn(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list loop ins: %w", err) + } + + // Get swap summary for budget and in-flight info. + summary := m.checkExistingAutoLoops(ctx, loopOut, loopIn) + + // Get swap traffic for failure and ongoing swap info. + traffic := m.currentSwapTraffic(loopOut, loopIn) + + // Get all channels. + channels, err := m.cfg.Lnd.Client.ListChannels(ctx, false, false) + if err != nil { + return nil, fmt.Errorf("failed to list channels: %w", err) + } + + // Get server restrictions. + loopOutRestrictions, err := m.cfg.Restrictions( + ctx, swap.TypeOut, getInitiator(m.params), + ) + if err != nil { + return nil, fmt.Errorf("failed to get loop out restrictions: %w", err) + } + + loopInRestrictions, err := m.cfg.Restrictions( + ctx, swap.TypeIn, getInitiator(m.params), + ) + if err != nil { + return nil, fmt.Errorf("failed to get loop in restrictions: %w", err) + } + + // Build channel info list and calculate totals. + var totalLocal, totalRemote, totalCapacity int64 + channelInfos := make([]script.ChannelInfo, 0, len(channels)) + peerMap := make(map[string]*script.PeerInfo) + + for _, ch := range channels { + isCustom := channelIsCustom(ch) + + // Skip custom channels for totals calculation. + if !isCustom { + totalLocal += int64(ch.LocalBalance) + totalRemote += int64(ch.RemoteBalance) + totalCapacity += int64(ch.Capacity) + } + + peerPubkey := hex.EncodeToString(ch.PubKeyBytes[:]) + shortChanID := lnwire.NewShortChanIDFromInt(ch.ChannelID) + + // Calculate percentages. + var localPercent, remotePercent float64 + if ch.Capacity > 0 { + localPercent = float64(ch.LocalBalance) / float64(ch.Capacity) * 100 + remotePercent = float64(ch.RemoteBalance) / float64(ch.Capacity) * 100 + } + + // Check for ongoing swaps. + hasLoopOut := traffic.ongoingLoopOut[shortChanID] + hasLoopIn := traffic.ongoingLoopIn[ch.PubKeyBytes] + + // Check for recent failures. + var failedAt int64 + recentlyFailed := false + if failTime, ok := traffic.failedLoopOut[shortChanID]; ok { + failedAt = failTime.Unix() + recentlyFailed = true + } + + info := script.ChannelInfo{ + ChannelID: ch.ChannelID, + PeerPubkey: peerPubkey, + Capacity: int64(ch.Capacity), + LocalBalance: int64(ch.LocalBalance), + RemoteBalance: int64(ch.RemoteBalance), + Active: ch.Active, + Private: ch.Private, + LocalPercent: localPercent, + RemotePercent: remotePercent, + HasLoopOutSwap: hasLoopOut, + HasLoopInSwap: hasLoopIn, + RecentlyFailed: recentlyFailed, + FailedAt: failedAt, + IsCustomChannel: isCustom, + } + channelInfos = append(channelInfos, info) + + // Aggregate peer info. + peer, ok := peerMap[peerPubkey] + if !ok { + peer = &script.PeerInfo{ + Pubkey: peerPubkey, + HasLoopInSwap: hasLoopIn, + } + peerMap[peerPubkey] = peer + } + peer.TotalCapacity += int64(ch.Capacity) + peer.TotalLocal += int64(ch.LocalBalance) + peer.TotalRemote += int64(ch.RemoteBalance) + peer.ChannelCount++ + peer.ChannelIDs = append(peer.ChannelIDs, ch.ChannelID) + } + + // Calculate peer percentages. + peers := make([]script.PeerInfo, 0, len(peerMap)) + for _, p := range peerMap { + if p.TotalCapacity > 0 { + p.LocalPercent = float64(p.TotalLocal) / float64(p.TotalCapacity) * 100 + p.RemotePercent = float64(p.TotalRemote) / float64(p.TotalCapacity) * 100 + } + peers = append(peers, *p) + } + + // Build in-flight channel/peer lists. + loopOutChannelIDs := make([]uint64, 0, len(traffic.ongoingLoopOut)) + for chanID := range traffic.ongoingLoopOut { + loopOutChannelIDs = append(loopOutChannelIDs, chanID.ToUint64()) + } + loopInPeerList := make([]string, 0, len(traffic.ongoingLoopIn)) + for peer := range traffic.ongoingLoopIn { + loopInPeerList = append(loopInPeerList, hex.EncodeToString(peer[:])) + } + + inFlight := script.InFlightInfo{ + LoopOutCount: len(traffic.ongoingLoopOut), + LoopInCount: len(traffic.ongoingLoopIn), + TotalCount: summary.inFlightCount, + MaxAllowed: m.params.MaxAutoInFlight, + LoopOutChannels: loopOutChannelIDs, + LoopInPeers: loopInPeerList, + } + + // Build budget info. + budget := script.BudgetInfo{ + TotalBudget: int64(m.params.AutoFeeBudget), + SpentAmount: int64(summary.spentFees), + PendingAmount: int64(summary.pendingFees), + RemainingAmount: int64(m.params.AutoFeeBudget) - int64(summary.spentFees) - int64(summary.pendingFees), + } + + return &script.AutoloopContext{ + Channels: channelInfos, + Peers: peers, + TotalLocal: totalLocal, + TotalRemote: totalRemote, + TotalCapacity: totalCapacity, + Restrictions: script.SwapRestrictions{ + MinLoopOut: int64(loopOutRestrictions.Minimum), + MaxLoopOut: int64(loopOutRestrictions.Maximum), + MinLoopIn: int64(loopInRestrictions.Minimum), + MaxLoopIn: int64(loopInRestrictions.Maximum), + }, + Budget: budget, + InFlight: inFlight, + CurrentTime: m.cfg.Clock.Now(), + }, nil +} + +// dispatchScriptableLoopOut dispatches a loop out swap based on a script +// decision. +func (m *Manager) dispatchScriptableLoopOut(ctx context.Context, + d script.SwapDecision, scriptCtx *script.AutoloopContext) error { + + if len(d.ChannelIDs) == 0 { + return fmt.Errorf("no channel IDs specified for loop out") + } + + // Get the first channel to determine the peer. + chanID := d.ChannelIDs[0] + var peerPubkey route.Vertex + for _, ch := range scriptCtx.Channels { + if ch.ChannelID == chanID { + pubkeyBytes, err := hex.DecodeString(ch.PeerPubkey) + if err != nil { + return fmt.Errorf("invalid peer pubkey: %w", err) + } + copy(peerPubkey[:], pubkeyBytes) + break + } + } + + // Build outgoing channel set. + outgoing := make([]lnwire.ShortChannelID, len(d.ChannelIDs)) + for i, id := range d.ChannelIDs { + outgoing[i] = lnwire.NewShortChanIDFromInt(id) + } + + // Use default fee params for scriptable mode (similar to easy autoloop). + params := m.params + switch feeLimit := params.FeeLimit.(type) { + case *FeePortion: + if feeLimit.PartsPerMillion == 0 { + params.FeeLimit = &FeePortion{ + PartsPerMillion: defaultFeePPM, + } + } + default: + params.FeeLimit = &FeePortion{ + PartsPerMillion: defaultFeePPM, + } + } + + // Build the swap. + builder := newLoopOutBuilder(m.cfg) + swapAmt := btcutil.Amount(d.Amount) + + suggestion, err := builder.buildSwap(ctx, peerPubkey, outgoing, swapAmt, params) + if err != nil { + return fmt.Errorf("failed to build swap: %w", err) + } + + loopOutSuggestion, ok := suggestion.(*loopOutSwapSuggestion) + if !ok { + return fmt.Errorf("unexpected suggestion type: %T", suggestion) + } + + log.Infof("scriptable autoloop: dispatching loop out for %v sats "+ + "via channel(s) %v", d.Amount, d.ChannelIDs) + + // Dispatch the sticky loop out. + go m.dispatchStickyLoopOut( + ctx, loopOutSuggestion.OutRequest, + defaultAmountBackoffRetry, defaultAmountBackoff, + ) + + return nil +} + +// dispatchScriptableLoopIn dispatches a loop in swap based on a script +// decision. +func (m *Manager) dispatchScriptableLoopIn(ctx context.Context, + d script.SwapDecision) error { + + if d.PeerPubkey == "" { + return fmt.Errorf("no peer pubkey specified for loop in") + } + + // Decode peer pubkey. + pubkeyBytes, err := hex.DecodeString(d.PeerPubkey) + if err != nil { + return fmt.Errorf("invalid peer pubkey: %w", err) + } + var lastHop route.Vertex + copy(lastHop[:], pubkeyBytes) + + // Build the loop in request. + params := m.params + switch feeLimit := params.FeeLimit.(type) { + case *FeePortion: + if feeLimit.PartsPerMillion == 0 { + params.FeeLimit = &FeePortion{ + PartsPerMillion: defaultFeePPM, + } + } + default: + params.FeeLimit = &FeePortion{ + PartsPerMillion: defaultFeePPM, + } + } + + // Get a quote for the loop in. + quote, err := m.cfg.LoopInQuote(ctx, &loop.LoopInQuoteRequest{ + Amount: btcutil.Amount(d.Amount), + HtlcConfTarget: params.HtlcConfTarget, + LastHop: &lastHop, + Initiator: getInitiator(params), + }) + if err != nil { + return fmt.Errorf("failed to get loop in quote: %w", err) + } + + // Check fees. + feeLimit, ok := params.FeeLimit.(*FeePortion) + if !ok { + return fmt.Errorf("unexpected fee limit type for loop in") + } + + maxFee := ppmToSat(btcutil.Amount(d.Amount), feeLimit.PartsPerMillion) + totalFees := quote.SwapFee + quote.MinerFee + if totalFees > maxFee { + return fmt.Errorf("loop in fees %v exceed max %v", totalFees, maxFee) + } + + log.Infof("scriptable autoloop: dispatching loop in for %v sats "+ + "with peer %s", d.Amount, d.PeerPubkey) + + // Dispatch the loop in. + _, err = m.cfg.LoopIn(ctx, &loop.LoopInRequest{ + Amount: btcutil.Amount(d.Amount), + MaxSwapFee: quote.SwapFee, + MaxMinerFee: quote.MinerFee, + HtlcConfTarget: params.HtlcConfTarget, + LastHop: &lastHop, + Initiator: getInitiator(params), + }) + if err != nil { + return fmt.Errorf("failed to dispatch loop in: %w", err) + } + + return nil +} From e4edd6e59abe1c6cbcea8b0b19d129f284c6b4eb Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Fri, 30 Jan 2026 20:41:55 +0100 Subject: [PATCH 4/5] looprpc: add scriptable autoloop RPC fields Add new fields to LiquidityParameters for scriptable autoloop: - scriptable_autoloop (bool): Enable scriptable autoloop mode - scriptable_script (string): The Starlark script content - scriptable_tick_interval_sec (uint64): Custom tick interval These fields allow clients to configure scriptable autoloop via the SetLiquidityParams RPC. The script is validated on the server side before being accepted. --- looprpc/client.pb.go | 45 +++++++++++++++++++++++++++++++++++-- looprpc/client.proto | 26 +++++++++++++++++++++ looprpc/client.swagger.json | 13 +++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index adb801eb9..9b8630eb5 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -3018,6 +3018,23 @@ type LiquidityParameters struct { // autoloop run. If set, channels connected to these peers won't be // considered for easy autoloop swaps. EasyAutoloopExcludedPeers [][]byte `protobuf:"bytes,27,rep,name=easy_autoloop_excluded_peers,json=easyAutoloopExcludedPeers,proto3" json:"easy_autoloop_excluded_peers,omitempty"` + // Set to true to enable scriptable autoloop using Starlark scripts. If set, + // all channel/peer rules and easy autoloop will be overridden and the client + // will evaluate the configured Starlark script on each tick to determine + // swaps. This mode is mutually exclusive with easy_autoloop and threshold + // rules. Starlark is a Python-like language that supports variables, + // functions, loops, and sorting - making it much more readable and powerful + // than simple expressions. + ScriptableAutoloop bool `protobuf:"varint,28,opt,name=scriptable_autoloop,json=scriptableAutoloop,proto3" json:"scriptable_autoloop,omitempty"` + // The Starlark script to evaluate on each autoloop tick. Required if + // scriptable_autoloop is true. The script must set a 'decisions' variable + // to a list of swap decisions using the loop_out() and loop_in() helper + // functions. See documentation for available context variables (channels, + // peers, total_local, restrictions, etc.) and helper functions. + ScriptableScript string `protobuf:"bytes,29,opt,name=scriptable_script,json=scriptableScript,proto3" json:"scriptable_script,omitempty"` + // Optional custom tick interval in seconds for scriptable autoloop mode. + // If 0 or not set, uses the default 20-minute interval. + ScriptableTickIntervalSec uint64 `protobuf:"varint,30,opt,name=scriptable_tick_interval_sec,json=scriptableTickIntervalSec,proto3" json:"scriptable_tick_interval_sec,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3242,6 +3259,27 @@ func (x *LiquidityParameters) GetEasyAutoloopExcludedPeers() [][]byte { return nil } +func (x *LiquidityParameters) GetScriptableAutoloop() bool { + if x != nil { + return x.ScriptableAutoloop + } + return false +} + +func (x *LiquidityParameters) GetScriptableScript() string { + if x != nil { + return x.ScriptableScript + } + return "" +} + +func (x *LiquidityParameters) GetScriptableTickIntervalSec() uint64 { + if x != nil { + return x.ScriptableTickIntervalSec + } + return 0 +} + type EasyAssetAutoloopParams struct { state protoimpl.MessageState `protogen:"open.v1"` // Set to true to enable easy autoloop for this asset. If set the client will @@ -6327,7 +6365,7 @@ const file_client_proto_rawDesc = "" + "\rloop_in_stats\x18\b \x01(\v2\x12.looprpc.LoopStatsR\vloopInStats\x12\x1f\n" + "\vcommit_hash\x18\t \x01(\tR\n" + "commitHash\"\x1b\n" + - "\x19GetLiquidityParamsRequest\"\xce\v\n" + + "\x19GetLiquidityParamsRequest\"\xed\f\n" + "\x13LiquidityParameters\x12,\n" + "\x05rules\x18\x01 \x03(\v2\x16.looprpc.LiquidityRuleR\x05rules\x12\x17\n" + "\afee_ppm\x18\x10 \x01(\x04R\x06feePpm\x12=\n" + @@ -6356,7 +6394,10 @@ const file_client_proto_rawDesc = "" + "\x11account_addr_type\x18\x18 \x01(\x0e2\x14.looprpc.AddressTypeR\x0faccountAddrType\x12]\n" + "\x11easy_asset_params\x18\x19 \x03(\v21.looprpc.LiquidityParameters.EasyAssetParamsEntryR\x0feasyAssetParams\x122\n" + "\x15fast_swap_publication\x18\x1a \x01(\bR\x13fastSwapPublication\x12?\n" + - "\x1ceasy_autoloop_excluded_peers\x18\x1b \x03(\fR\x19easyAutoloopExcludedPeers\x1ad\n" + + "\x1ceasy_autoloop_excluded_peers\x18\x1b \x03(\fR\x19easyAutoloopExcludedPeers\x12/\n" + + "\x13scriptable_autoloop\x18\x1c \x01(\bR\x12scriptableAutoloop\x12+\n" + + "\x11scriptable_script\x18\x1d \x01(\tR\x10scriptableScript\x12?\n" + + "\x1cscriptable_tick_interval_sec\x18\x1e \x01(\x04R\x19scriptableTickIntervalSec\x1ad\n" + "\x14EasyAssetParamsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x126\n" + "\x05value\x18\x02 \x01(\v2 .looprpc.EasyAssetAutoloopParamsR\x05value:\x028\x01\"h\n" + diff --git a/looprpc/client.proto b/looprpc/client.proto index eba7aca40..33ff0416b 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -1287,6 +1287,32 @@ message LiquidityParameters { considered for easy autoloop swaps. */ repeated bytes easy_autoloop_excluded_peers = 27; + + /* + Set to true to enable scriptable autoloop using Starlark scripts. If set, + all channel/peer rules and easy autoloop will be overridden and the client + will evaluate the configured Starlark script on each tick to determine + swaps. This mode is mutually exclusive with easy_autoloop and threshold + rules. Starlark is a Python-like language that supports variables, + functions, loops, and sorting - making it much more readable and powerful + than simple expressions. + */ + bool scriptable_autoloop = 28; + + /* + The Starlark script to evaluate on each autoloop tick. Required if + scriptable_autoloop is true. The script must set a 'decisions' variable + to a list of swap decisions using the loop_out() and loop_in() helper + functions. See documentation for available context variables (channels, + peers, total_local, restrictions, etc.) and helper functions. + */ + string scriptable_script = 29; + + /* + Optional custom tick interval in seconds for scriptable autoloop mode. + If 0 or not set, uses the default 20-minute interval. + */ + uint64 scriptable_tick_interval_sec = 30; } message EasyAssetAutoloopParams { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index ac34717dc..f6bb8ef52 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1796,6 +1796,19 @@ "format": "byte" }, "description": "A list of peers (their public keys) that should be excluded from the easy\nautoloop run. If set, channels connected to these peers won't be\nconsidered for easy autoloop swaps." + }, + "scriptable_autoloop": { + "type": "boolean", + "description": "Set to true to enable scriptable autoloop using Starlark scripts. If set,\nall channel/peer rules and easy autoloop will be overridden and the client\nwill evaluate the configured Starlark script on each tick to determine\nswaps. This mode is mutually exclusive with easy_autoloop and threshold\nrules. Starlark is a Python-like language that supports variables,\nfunctions, loops, and sorting - making it much more readable and powerful\nthan simple expressions." + }, + "scriptable_script": { + "type": "string", + "description": "The Starlark script to evaluate on each autoloop tick. Required if\nscriptable_autoloop is true. The script must set a 'decisions' variable\nto a list of swap decisions using the loop_out() and loop_in() helper\nfunctions. See documentation for available context variables (channels,\npeers, total_local, restrictions, etc.) and helper functions." + }, + "scriptable_tick_interval_sec": { + "type": "string", + "format": "uint64", + "description": "Optional custom tick interval in seconds for scriptable autoloop mode.\nIf 0 or not set, uses the default 20-minute interval." } } }, From b62e81b307295697748581e6480c3402e6d7ab92 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Fri, 30 Jan 2026 20:42:08 +0100 Subject: [PATCH 5/5] cmd/loop: add CLI support for scriptable autoloop Add new flags to 'loop setparams' for configuring scriptable autoloop: - --scriptautoloop: Enable/disable scriptable autoloop mode - --scriptfile: Path to a Starlark script file (recommended for production - enables version control, syntax highlighting, and proper editing workflows) - --script: Inline Starlark script for simple cases - --scripttickinterval: Custom tick interval in seconds Example usage: # Enable with script file (recommended) loop setparams --scriptautoloop --scriptfile /path/to/autoloop.star # Enable with inline script loop setparams --scriptautoloop --script 'decisions = []' # Disable loop setparams --scriptautoloop=false The --scriptfile and --script flags are mutually exclusive. Scriptable autoloop cannot be used together with easy autoloop. --- cmd/loop/liquidity.go | 71 ++++++++++++++++++++++++++++ docs/loop.1 | 12 +++++ docs/loop.md | 4 ++ liquidity/liquidity.go | 11 +++-- liquidity/parameters.go | 18 +++---- liquidity/script/builtins.go | 4 +- liquidity/script_equivalence_test.go | 6 ++- 7 files changed, 110 insertions(+), 16 deletions(-) diff --git a/cmd/loop/liquidity.go b/cmd/loop/liquidity.go index 5492f2045..f4e0b7e00 100644 --- a/cmd/loop/liquidity.go +++ b/cmd/loop/liquidity.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "os" "strconv" "strings" @@ -390,6 +391,34 @@ var setParamsCommand = &cli.Command{ "save on chain fees. Not setting this flag " + "therefore might result in a lower swap fees", }, + &cli.BoolFlag{ + Name: "scriptautoloop", + Usage: "set to true to enable scriptable autoloop " + + "using Starlark scripts. This allows custom " + + "swap logic with variables, functions, loops, " + + "and sorting. Mutually exclusive with " + + "easyautoloop and threshold rules.", + }, + &cli.StringFlag{ + Name: "scriptfile", + Usage: "path to a Starlark script file for " + + "scriptable autoloop. The script must set " + + "a 'decisions' variable to a list of swaps. " + + "Recommended for production use as it allows " + + "version control and proper editing.", + }, + &cli.StringFlag{ + Name: "script", + Usage: "inline Starlark script for scriptable " + + "autoloop. For simple scripts only; use " + + "--scriptfile for complex scripts.", + }, + &cli.Uint64Flag{ + Name: "scripttickinterval", + Usage: "custom tick interval in seconds for " + + "scriptable autoloop. If not set, uses " + + "the default 20-minute interval.", + }, }, Action: setParams, } @@ -637,6 +666,48 @@ func setParams(ctx context.Context, cmd *cli.Command) error { params.FastSwapPublication = true } + // Handle scriptable autoloop flags. + if cmd.IsSet("scriptautoloop") { + params.ScriptableAutoloop = cmd.Bool("scriptautoloop") + flagSet = true + } + + // Handle script content from file or inline. + scriptFileSet := cmd.IsSet("scriptfile") + scriptInlineSet := cmd.IsSet("script") + + if scriptFileSet && scriptInlineSet { + return fmt.Errorf("cannot set both --scriptfile and --script; " + + "use one or the other") + } + + if scriptFileSet { + scriptPath := cmd.String("scriptfile") + scriptBytes, err := os.ReadFile(scriptPath) + if err != nil { + return fmt.Errorf("failed to read script file %s: %w", + scriptPath, err) + } + params.ScriptableScript = string(scriptBytes) + flagSet = true + } + + if scriptInlineSet { + params.ScriptableScript = cmd.String("script") + flagSet = true + } + + if cmd.IsSet("scripttickinterval") { + params.ScriptableTickIntervalSec = cmd.Uint64("scripttickinterval") + flagSet = true + } + + // Validate that scriptable autoloop is not used with easyautoloop. + if params.ScriptableAutoloop && params.EasyAutoloop { + return fmt.Errorf("scriptable autoloop cannot be used with " + + "easy autoloop; disable one before enabling the other") + } + if !flagSet { return fmt.Errorf("at least one flag required to set params") } diff --git a/docs/loop.1 b/docs/loop.1 index 20b4f7a58..321fc6539 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -380,6 +380,18 @@ update the parameters set for the liquidity manager .PP \fB--minamt\fP="": the minimum amount in satoshis that the autoloop client will dispatch per-swap. (default: 0) +.PP +\fB--script\fP="": inline Starlark script for scriptable autoloop. For simple scripts only; use --scriptfile for complex scripts. + +.PP +\fB--scriptautoloop\fP: set to true to enable scriptable autoloop using Starlark scripts. This allows custom swap logic with variables, functions, loops, and sorting. Mutually exclusive with easyautoloop and threshold rules. + +.PP +\fB--scriptfile\fP="": path to a Starlark script file for scriptable autoloop. The script must set a 'decisions' variable to a list of swaps. Recommended for production use as it allows version control and proper editing. + +.PP +\fB--scripttickinterval\fP="": custom tick interval in seconds for scriptable autoloop. If not set, uses the default 20-minute interval. (default: 0) + .PP \fB--sweepconf\fP="": the number of blocks from htlc height that swap suggestion sweeps should target, used to estimate max miner fee. (default: 0) diff --git a/docs/loop.md b/docs/loop.md index ebe3a5c9d..30af6f73f 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -357,6 +357,10 @@ The following flags are supported: | `--asset_id="…"` | If set to a valid asset ID, the easyautoloop and localbalancesat flags will be set for the specified asset | string | | `--asset_localbalance="…"` | the target size of total local balance in asset units, used by asset easy autoloop | uint | `0` | | `--fast` | if set new swaps are expected to be published immediately, paying a potentially higher fee. If not set the swap server might choose to wait up to 30 minutes before publishing swap HTLCs on-chain, to save on chain fees. Not setting this flag therefore might result in a lower swap fees | bool | `false` | +| `--scriptautoloop` | set to true to enable scriptable autoloop using Starlark scripts. This allows custom swap logic with variables, functions, loops, and sorting. Mutually exclusive with easyautoloop and threshold rules | bool | `false` | +| `--scriptfile="…"` | path to a Starlark script file for scriptable autoloop. The script must set a 'decisions' variable to a list of swaps. Recommended for production use as it allows version control and proper editing | string | +| `--script="…"` | inline Starlark script for scriptable autoloop. For simple scripts only; use --scriptfile for complex scripts | string | +| `--scripttickinterval="…"` | custom tick interval in seconds for scriptable autoloop. If not set, uses the default 20-minute interval | uint | `0` | | `--help` (`-h`) | show help | bool | `false` | ### `getinfo` command diff --git a/liquidity/liquidity.go b/liquidity/liquidity.go index eefad196d..dabab1d88 100644 --- a/liquidity/liquidity.go +++ b/liquidity/liquidity.go @@ -295,19 +295,22 @@ func (m *Manager) Run(ctx context.Context) error { case <-m.cfg.AutoloopTicker.Ticks(): // Check which autoloop mode to use. // Priority: scriptable > easy > threshold rules. - if m.params.ScriptableAutoloop { + switch { + case m.params.ScriptableAutoloop: err := m.scriptableAutoLoop(ctx) if err != nil { log.Errorf("scriptable autoloop "+ "failed: %v", err) } - } else if m.params.EasyAutoloop { + + case m.params.EasyAutoloop: err := m.easyAutoLoop(ctx) if err != nil { log.Errorf("easy autoloop failed: %v", err) } - } else { + + default: err := m.autoloop(ctx) switch err { case ErrNoRules: @@ -1503,6 +1506,8 @@ func (m *Manager) refreshAutoloopBudget(ctx context.Context) { // dispatchStickyLoopOut attempts to dispatch a loop out swap that will // automatically retry its execution with an amount based backoff. +// +//nolint:unparam func (m *Manager) dispatchStickyLoopOut(ctx context.Context, out loop.OutRequest, retryCount uint16, amountBackoff float64) { diff --git a/liquidity/parameters.go b/liquidity/parameters.go index 18a8c82d1..8bcd19636 100644 --- a/liquidity/parameters.go +++ b/liquidity/parameters.go @@ -129,11 +129,11 @@ type Parameters struct { // If set to false, the deadline is set to 30 minutes. FastSwapPublication bool - // ScriptableAutoloop enables CEL-based scriptable autoloop mode. + // ScriptableAutoloop enables Starlark-based scriptable autoloop mode. // This mode is mutually exclusive with EasyAutoloop and threshold rules. ScriptableAutoloop bool - // ScriptableScript is the CEL expression to evaluate on each tick. + // ScriptableScript is the Starlark script to evaluate on each tick. // Required when ScriptableAutoloop is true. ScriptableScript string @@ -653,13 +653,13 @@ func ParametersToRpc(cfg Parameters) (*clientrpc.LiquidityParameters, HtlcConfTarget: cfg.HtlcConfTarget, EasyAutoloop: cfg.EasyAutoloop, EasyAutoloopLocalTargetSat: uint64(cfg.EasyAutoloopTarget), - Account: cfg.Account, - AccountAddrType: addrType, - EasyAssetParams: easyAssetMap, - FastSwapPublication: cfg.FastSwapPublication, - ScriptableAutoloop: cfg.ScriptableAutoloop, - ScriptableScript: cfg.ScriptableScript, - ScriptableTickIntervalSec: uint64(cfg.ScriptableTickInterval / time.Second), + Account: cfg.Account, + AccountAddrType: addrType, + EasyAssetParams: easyAssetMap, + FastSwapPublication: cfg.FastSwapPublication, + ScriptableAutoloop: cfg.ScriptableAutoloop, + ScriptableScript: cfg.ScriptableScript, + ScriptableTickIntervalSec: uint64(cfg.ScriptableTickInterval / time.Second), } // Set excluded peers for easy autoloop. rpcCfg.EasyAutoloopExcludedPeers = make( diff --git a/liquidity/script/builtins.go b/liquidity/script/builtins.go index b8a0bf36e..9967272df 100644 --- a/liquidity/script/builtins.go +++ b/liquidity/script/builtins.go @@ -7,7 +7,7 @@ import ( ) // loopOutBuiltin creates a loop out swap decision. -// Usage: loop_out(amount, channel_ids) or loop_out(amount, channel_ids, priority) +// Usage: loop_out(amount, channel_ids) or loop_out(amount, channel_ids, priority). func loopOutBuiltin(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { @@ -65,7 +65,7 @@ func loopOutBuiltin(thread *starlark.Thread, fn *starlark.Builtin, } // loopInBuiltin creates a loop in swap decision. -// Usage: loop_in(amount, peer_pubkey) or loop_in(amount, peer_pubkey, priority) +// Usage: loop_in(amount, peer_pubkey) or loop_in(amount, peer_pubkey, priority). func loopInBuiltin(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { diff --git a/liquidity/script_equivalence_test.go b/liquidity/script_equivalence_test.go index 449e5337d..ca144d9ff 100644 --- a/liquidity/script_equivalence_test.go +++ b/liquidity/script_equivalence_test.go @@ -20,7 +20,7 @@ import ( ) // EasyAutoloopStarlarkScript is a Starlark script that replicates easy-autoloop. -// Target: 100000 sats (embedded in script) +// Target: 100000 sats (embedded in script). const EasyAutoloopStarlarkScript = ` # Easy autoloop equivalent script in Starlark # Target: 100000 sats @@ -58,7 +58,7 @@ def autoloop(): swap_amount = min(best.local_balance, amount) return [loop_out(swap_amount, [best.channel_id])] -# Execute and return decisions +# Execute and store result. decisions = autoloop() ` @@ -335,6 +335,7 @@ func TestScriptableAutoloopIntegration(t *testing.T) { }, Restrictions: func(ctx context.Context, swapType swap.Type, initiator string) (*Restrictions, error) { + return &Restrictions{ Minimum: 10000, Maximum: 1000000, @@ -342,6 +343,7 @@ func TestScriptableAutoloopIntegration(t *testing.T) { }, LoopOutQuote: func(ctx context.Context, req *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) { + return &loop.LoopOutQuote{ SwapFee: 100, PrepayAmount: 1000,