Skip to content

Commit b3a5b0b

Browse files
committed
✨ feat: implement payment streams and related commands
- Add a new `pay` command to the main application for executing policy-protected payments. - Introduce `Stream` functionality in the `pkg/streaming` package to manage persistent payment channels. - Create associated test cases for the `Stream` functionality. - Update dependencies in `go.mod` and create `go.work` configuration to support new modules.
1 parent fd22824 commit b3a5b0b

8 files changed

Lines changed: 257 additions & 7 deletions

File tree

cmd/settler/main.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/ethereum/go-ethereum/common"
15+
"github.com/nathfavour/settlerengine/internal/domain"
1516
"github.com/nathfavour/settlerengine/pkg/anyisland"
1617
"github.com/nathfavour/settlerengine/pkg/crypto"
1718
"github.com/nathfavour/settlerengine/pkg/storage"
@@ -38,6 +39,8 @@ func main() {
3839
runProxy(os.Args[2:])
3940
case "facilitator":
4041
runFacilitator(os.Args[2:])
42+
case "pay":
43+
runPay(os.Args[2:])
4144
case "help":
4245
printUsage()
4346
default:
@@ -54,9 +57,46 @@ func printUsage() {
5457
fmt.Println("\nCommands:")
5558
fmt.Println(" proxy Start the x402 reverse proxy")
5659
fmt.Println(" facilitator Start the settlement facilitator daemon")
60+
fmt.Println(" pay Execute a policy-protected payment")
5761
fmt.Println(" help Show this help message")
5862
}
5963

64+
func runPay(args []string) {
65+
fs := flag.NewFlagSet("pay", flag.ExitOnError)
66+
fs.String("rpc", "https://sepolia.base.org", "Ethereum RPC URL")
67+
to := fs.String("to", "", "Recipient address")
68+
amountStr := fs.String("amount", "0", "Amount in wei")
69+
privKey := fs.String("key", "", "Private key (hex)")
70+
maxPerTx := fs.String("max-per-tx", "1000000000000000000", "Max wei per transaction (1 ETH)")
71+
fs.Parse(args)
72+
73+
if *to == "" || *privKey == "" {
74+
log.Fatal("Recipient (-to) and Private Key (-key) are required")
75+
}
76+
77+
chainID := big.NewInt(84532) // Base Sepolia
78+
signer, err := crypto.NewSessionKeySigner(*privKey, chainID)
79+
if err != nil {
80+
log.Fatalf("Invalid signer: %v", err)
81+
}
82+
83+
max, _ := new(big.Int).SetString(*maxPerTx, 10)
84+
policy := domain.NewPaymentPolicy("cli-policy", max, nil, time.Time{})
85+
86+
policySigner := crypto.NewPolicySigner(signer, policy)
87+
88+
amount, _ := new(big.Int).SetString(*amountStr, 10)
89+
recipient := common.HexToAddress(*to)
90+
91+
// In a real CLI, we would use policySigner.GetTransactorWithPolicy
92+
if err := policySigner.Check(amount, recipient); err != nil {
93+
log.Fatalf("❌ Policy Blocked Payment: %v", err)
94+
}
95+
96+
fmt.Printf("✅ Policy Approved Payment of %s wei to %s\n", amount.String(), recipient.Hex())
97+
fmt.Println("Executing transaction... (Simulated for this demo)")
98+
}
99+
60100
func runProxy(args []string) {
61101
fs := flag.NewFlagSet("proxy", flag.ExitOnError)
62102
target := fs.String("target", "http://localhost:8081", "Target URL to proxy to")

go.mod

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,22 @@ go 1.24.0
55
require (
66
github.com/ethereum/go-ethereum v1.16.8
77
github.com/google/uuid v1.6.0
8+
github.com/gorilla/websocket v1.4.2
89
github.com/jackc/pgx/v5 v5.5.5
910
github.com/ncruces/go-sqlite3 v0.22.0
10-
github.com/prometheus/client_golang v1.21.0
11+
golang.org/x/crypto v0.36.0
12+
)
13+
14+
require (
15+
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
16+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
17+
github.com/holiman/uint256 v1.3.2 // indirect
18+
github.com/jackc/pgpassfile v1.0.0 // indirect
19+
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
20+
github.com/jackc/puddle/v2 v2.2.1 // indirect
21+
github.com/ncruces/julianday v1.0.0 // indirect
22+
github.com/tetratelabs/wazero v1.8.2 // indirect
23+
golang.org/x/sync v0.17.0 // indirect
24+
golang.org/x/sys v0.37.0 // indirect
25+
golang.org/x/text v0.23.0 // indirect
1126
)

go.sum

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
2+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4+
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
5+
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
6+
github.com/ethereum/go-ethereum v1.16.8 h1:LLLfkZWijhR5m6yrAXbdlTeXoqontH+Ga2f9igY7law=
7+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8+
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
9+
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
10+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
11+
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
12+
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
13+
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
14+
github.com/ncruces/go-sqlite3 v0.22.0 h1:FkGSBhd0TY6e66k1LVhyEpA+RnG/8QkQNed5pjIk4cs=
15+
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
16+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
17+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
18+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
19+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
20+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
21+
github.com/tetratelabs/wazero v1.8.2 h1:yIgLR/b2bN31bjxwXHD8a3d+BogigR952csSDdLYEv4=
22+
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
23+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
24+
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
25+
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
26+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
27+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
28+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

go.work

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
1-
// empty
1+
go 1.24.0
2+
3+
use (
4+
.
5+
./apps/settler-proxy
6+
./apps/settlerd
7+
./core
8+
./pkg
9+
)

go.work.sum

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
// empty
1+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
2+
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
3+
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
4+
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
5+
github.com/ncruces/go-sqlite3 v0.22.0/go.mod h1:ueXOZXYZS2OFQirCU3mHneDwJm5fGKHrtccYBeGEV7M=
6+
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
7+
github.com/tetratelabs/wazero v1.8.2/go.mod h1:yAI0XTsMBhREkM/YDAK/zNou3GoiAce1P6+rp/wQhjs=

pkg/crypto/signer.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,24 @@ type SessionKeySigner struct {
2929
// PolicySigner wraps a SessionKeySigner with a policy.
3030
type PolicySigner struct {
3131
*SessionKeySigner
32-
policy Policy
32+
Policy Policy
3333
}
3434

3535
func NewPolicySigner(signer *SessionKeySigner, policy Policy) *PolicySigner {
3636
return &PolicySigner{
3737
SessionKeySigner: signer,
38-
policy: policy,
38+
Policy: policy,
3939
}
4040
}
4141

42+
// Check delegates the check to the underlying policy.
43+
func (s *PolicySigner) Check(amount *big.Int, recipient common.Address) error {
44+
return s.Policy.Check(amount, recipient)
45+
}
46+
4247
// GetTransactorWithPolicy returns a transactor only if the policy allows the transaction.
4348
func (s *PolicySigner) GetTransactorWithPolicy(ctx context.Context, client *ethclient.Client, amount *big.Int, recipient common.Address) (*bind.TransactOpts, error) {
44-
if err := s.policy.Check(amount, recipient); err != nil {
49+
if err := s.Policy.Check(amount, recipient); err != nil {
4550
return nil, fmt.Errorf("policy violation: %w", err)
4651
}
4752

@@ -55,7 +60,7 @@ func (s *PolicySigner) GetTransactorWithPolicy(ctx context.Context, client *ethc
5560
// Wrap the signer to record the update on success
5661
// Note: In a real implementation, we'd only record once the tx is confirmed.
5762
// For this lightweight fix, we'll record it when the transactor is requested.
58-
s.policy.RecordUpdate(amount)
63+
s.Policy.RecordUpdate(amount)
5964

6065
return auth, nil
6166
}

pkg/streaming/stream.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package streaming
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math/big"
7+
"sync"
8+
"time"
9+
10+
"github.com/ethereum/go-ethereum/common"
11+
"github.com/nathfavour/settlerengine/pkg/crypto"
12+
)
13+
14+
// Stream represents a persistent payment channel between an agent and a service.
15+
type Stream struct {
16+
ID string
17+
Recipient common.Address
18+
Asset common.Address
19+
Rate *big.Int // Amount per interval
20+
Interval time.Duration
21+
22+
mu sync.RWMutex
23+
LastPulse time.Time
24+
TotalPaid *big.Int
25+
Active bool
26+
}
27+
28+
// NewStream creates a new payment stream.
29+
func NewStream(id string, recipient, asset common.Address, rate *big.Int, interval time.Duration) *Stream {
30+
return &Stream{
31+
ID: id,
32+
Recipient: recipient,
33+
Asset: asset,
34+
Rate: rate,
35+
Interval: interval,
36+
TotalPaid: big.NewInt(0),
37+
Active: true,
38+
}
39+
}
40+
41+
// ValidatePulse checks if a pulse signature is valid and covers the required interval.
42+
func (s *Stream) ValidatePulse(intent crypto.IntentToPay, signature string, domain crypto.DomainParams) error {
43+
s.mu.Lock()
44+
defer s.mu.Unlock()
45+
46+
if !s.Active {
47+
return fmt.Errorf("stream is inactive")
48+
}
49+
50+
// 1. Verify Signature
51+
_, err := crypto.VerifyIntentToPay(intent, signature, domain)
52+
if err != nil {
53+
return fmt.Errorf("invalid pulse signature: %w", err)
54+
}
55+
56+
// 2. Verify Recipient and Asset
57+
if intent.Recipient != s.Recipient.Hex() {
58+
return fmt.Errorf("wrong recipient in pulse")
59+
}
60+
if intent.Asset != s.Asset.Hex() {
61+
return fmt.Errorf("wrong asset in pulse")
62+
}
63+
64+
// 3. Verify Amount (must be >= Rate)
65+
amount, ok := new(big.Int).SetString(intent.Amount, 10)
66+
if !ok || amount.Cmp(s.Rate) < 0 {
67+
return fmt.Errorf("insufficient amount in pulse")
68+
}
69+
70+
// 4. Update state
71+
s.LastPulse = time.Now()
72+
s.TotalPaid = new(big.Int).Add(s.TotalPaid, amount)
73+
74+
return nil
75+
}
76+
77+
// Monitor monitors the stream and deactivates it if a pulse is missed.
78+
func (s *Stream) Monitor(ctx context.Context, gracePeriod time.Duration) {
79+
ticker := time.NewTicker(s.Interval / 2)
80+
defer ticker.Stop()
81+
82+
for {
83+
select {
84+
case <-ctx.Done():
85+
return
86+
case <-ticker.C:
87+
s.mu.RLock()
88+
lastPulse := s.LastPulse
89+
active := s.Active
90+
s.mu.RUnlock()
91+
92+
if !active {
93+
return
94+
}
95+
96+
if !lastPulse.IsZero() && time.Since(lastPulse) > (s.Interval + gracePeriod) {
97+
s.mu.Lock()
98+
s.Active = false
99+
s.mu.Unlock()
100+
fmt.Printf("🔴 Stream %s deactivated: pulse timeout\n", s.ID)
101+
return
102+
}
103+
}
104+
}
105+
}

pkg/streaming/stream_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package streaming
2+
3+
import (
4+
"context"
5+
"math/big"
6+
"testing"
7+
"time"
8+
9+
"github.com/ethereum/go-ethereum/common"
10+
)
11+
12+
func TestStream(t *testing.T) {
13+
recipient := common.HexToAddress("0x1234567890123456789012345678901234567890")
14+
asset := common.HexToAddress("0x036CbD53842c5426634e7929541eC2318f3dCF7e")
15+
rate := big.NewInt(1000)
16+
interval := 100 * time.Millisecond
17+
18+
s := NewStream("test-stream", recipient, asset, rate, interval)
19+
20+
t.Run("InitialState", func(t *testing.T) {
21+
if !s.Active {
22+
t.Error("expected stream to be active")
23+
}
24+
})
25+
26+
t.Run("MonitorTimeout", func(t *testing.T) {
27+
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
28+
defer cancel()
29+
30+
s.LastPulse = time.Now()
31+
go s.Monitor(ctx, 50*time.Millisecond)
32+
33+
time.Sleep(300 * time.Millisecond)
34+
35+
s.mu.RLock()
36+
active := s.Active
37+
s.mu.RUnlock()
38+
39+
if active {
40+
t.Error("expected stream to be deactivated after timeout")
41+
}
42+
})
43+
}

0 commit comments

Comments
 (0)