Skip to content

Commit 0fc0b8c

Browse files
authored
feat(cli): Signup Tx Build (#169)
Signed-off-by: Akhil Repala <arepala@blinklabs.io>
1 parent 1734518 commit 0fc0b8c

8 files changed

Lines changed: 457 additions & 6 deletions

File tree

cmd/vpn-cli/datum_ref.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/blinklabs-io/gouroboros/cbor"
8+
)
9+
10+
type plan struct {
11+
Duration int
12+
Price int
13+
}
14+
15+
func decodeRefDatumFlexible(datumCBOR []byte) ([]plan, []string, error) {
16+
// Decode CBOR into any first
17+
var v any
18+
_, err := cbor.Decode(datumCBOR, &v)
19+
if err != nil {
20+
return nil, nil, fmt.Errorf("cbor decode: %w", err)
21+
}
22+
23+
// Remove CBOR wrappers (Tag/Constructors)
24+
v = unwrapAll(v)
25+
26+
// We expect either:
27+
// [ ....,...] OR Constructor( [ plans, regions, ... ] )
28+
seq, ok := toSlice(v)
29+
if !ok {
30+
return nil, nil, errors.New("refdatum: top-level is not a list/constructor")
31+
}
32+
33+
// conisdered first list of (int,int) as plans and the first "list of string" as regions.
34+
var rawPlans any
35+
var rawRegions any
36+
for _, e := range seq {
37+
eu := unwrapAll(e)
38+
if rawPlans == nil {
39+
if ps, ok := isListOfIntPairs(eu); ok {
40+
rawPlans = ps
41+
continue
42+
}
43+
}
44+
if rawRegions == nil {
45+
if rs, ok := isListOfStrings(eu); ok {
46+
rawRegions = rs
47+
continue
48+
}
49+
}
50+
}
51+
if rawPlans == nil {
52+
return nil, nil, fmt.Errorf("refdatum: plans not found")
53+
}
54+
pairs := rawPlans.([][]int)
55+
outPlans := make([]plan, 0, len(pairs))
56+
for _, p := range pairs {
57+
outPlans = append(outPlans, plan{Duration: p[0], Price: p[1]})
58+
}
59+
60+
if rawRegions == nil {
61+
return nil, nil, fmt.Errorf("refdatum: regions not found")
62+
}
63+
outRegions := rawRegions.([]string)
64+
65+
return outPlans, outRegions, nil
66+
}
67+
68+
// Removes cbor.Tag and cbor.Constructor.
69+
func unwrapAll(x any) any {
70+
for {
71+
switch t := x.(type) {
72+
case cbor.Tag:
73+
x = t.Content
74+
continue
75+
case *cbor.Constructor:
76+
fields := t.Fields()
77+
if len(fields) == 1 {
78+
x = fields[0]
79+
continue
80+
}
81+
x = fields
82+
continue
83+
case cbor.Constructor:
84+
if len(t.Fields()) == 1 {
85+
x = t.Fields()[0]
86+
continue
87+
}
88+
x = t.Fields()
89+
continue
90+
default:
91+
return x
92+
}
93+
}
94+
}
95+
96+
// toSlice returns v as []any
97+
func toSlice(v any) ([]any, bool) {
98+
s, ok := v.([]any)
99+
return s, ok
100+
}
101+
102+
// Read a list as [[int,int], ...] even if each pair is []any.
103+
func isListOfIntPairs(v any) ([][]int, bool) {
104+
items, ok := toSlice(v)
105+
if !ok || len(items) == 0 {
106+
return nil, false
107+
}
108+
out := make([][]int, 0, len(items))
109+
for _, it := range items {
110+
// unwrap constructor to CBOR fields
111+
it = unwrapAll(it)
112+
pair, ok := toSlice(it)
113+
if !ok || len(pair) != 2 {
114+
return nil, false
115+
}
116+
a, okA := toInt(unwrapAll(pair[0]))
117+
b, okB := toInt(unwrapAll(pair[1]))
118+
if !okA || !okB {
119+
return nil, false
120+
}
121+
out = append(out, []int{a, b})
122+
}
123+
return out, true
124+
}
125+
126+
// Can be either strings or []byte (UTF-8) per element.
127+
func isListOfStrings(v any) ([]string, bool) {
128+
items, ok := toSlice(v)
129+
if !ok {
130+
return nil, false
131+
}
132+
out := make([]string, 0, len(items))
133+
for _, it := range items {
134+
it = unwrapAll(it)
135+
switch s := it.(type) {
136+
case string:
137+
out = append(out, s)
138+
case []byte:
139+
out = append(out, string(s))
140+
default:
141+
return nil, false
142+
}
143+
}
144+
return out, true
145+
}
146+
147+
// convert any CBOR number into an int.
148+
func toInt(v any) (int, bool) {
149+
switch n := v.(type) {
150+
case int:
151+
return n, true
152+
case int64:
153+
return int(n), true
154+
case uint64:
155+
return int(n), true
156+
case uint:
157+
return int(n), true
158+
case float64:
159+
if n == float64(int(n)) {
160+
return int(n), true
161+
}
162+
return 0, false
163+
default:
164+
return 0, false
165+
}
166+
}

cmd/vpn-cli/kupo_ref.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/SundaeSwap-finance/kugo"
11+
"github.com/blinklabs-io/vpn-indexer/internal/config"
12+
"github.com/blinklabs-io/vpn-indexer/internal/database"
13+
)
14+
15+
func getKupoClient() (*kugo.Client, error) {
16+
cfg := config.GetConfig()
17+
if cfg.TxBuilder.KupoUrl == "" {
18+
return nil, errors.New("no kupo url provided")
19+
}
20+
k := kugo.New(kugo.WithEndpoint(cfg.TxBuilder.KupoUrl))
21+
if k == nil {
22+
return nil, fmt.Errorf("failed kupo client: %s", cfg.TxBuilder.KupoUrl)
23+
}
24+
return k, nil
25+
}
26+
27+
func loadReferenceFromKugoClient(ctx context.Context) (database.Reference, error) {
28+
cfg := config.GetConfig()
29+
if strings.TrimSpace(cfg.TxBuilder.KupoUrl) == "" {
30+
return database.Reference{}, errors.New("kupo url not configured")
31+
}
32+
k, err := getKupoClient()
33+
if err != nil {
34+
return database.Reference{}, err
35+
}
36+
37+
if tok := strings.TrimSpace(cfg.Indexer.ReferenceToken); tok != "" {
38+
if ref, err := refByKugoMatches(ctx, k, tok); err == nil {
39+
return ref, nil
40+
}
41+
}
42+
if addr := strings.TrimSpace(cfg.Indexer.ScriptAddress); addr != "" {
43+
return refByKugoMatches(ctx, k, addr)
44+
}
45+
46+
return database.Reference{}, errors.New("neither reference token nor script address configured")
47+
}
48+
49+
func refByKugoMatches(ctx context.Context, k *kugo.Client, pattern string) (database.Reference, error) {
50+
// Query matches by pattern (address).
51+
matches, err := k.Matches(ctx, kugo.Pattern(pattern))
52+
if err != nil {
53+
return database.Reference{}, fmt.Errorf("kupo matches: %w", err)
54+
}
55+
if len(matches) == 0 {
56+
return database.Reference{}, fmt.Errorf("no matches for pattern %q", pattern)
57+
}
58+
match := matches[0]
59+
60+
// Decode transaction_id (bytes to hex)
61+
txid, err := hex.DecodeString(parseHex(match.TransactionID))
62+
if err != nil {
63+
return database.Reference{}, fmt.Errorf("txid decode: %w", err)
64+
}
65+
66+
// Acquire datum CBOR using kugo only
67+
var datumCBOR []byte
68+
switch {
69+
case strings.TrimSpace(getDatumHash(match)) != "":
70+
hexStr, derr := k.Datum(ctx, parseHex(getDatumHash(match)))
71+
if derr != nil {
72+
return database.Reference{}, fmt.Errorf("fetch datum by hash: %w", derr)
73+
}
74+
datumCBOR, err = hex.DecodeString(parseHex(hexStr))
75+
if err != nil {
76+
return database.Reference{}, fmt.Errorf("datum decode: %w", err)
77+
}
78+
default:
79+
return database.Reference{}, errors.New("reference utxo has no datum (no bytes/hash)")
80+
}
81+
82+
// Decode reference plans and regions from datumCBOR
83+
plans, regions, err := decodeRefDatumFlexible(datumCBOR)
84+
if err != nil {
85+
return database.Reference{}, fmt.Errorf("decode ref datum: %w", err)
86+
}
87+
88+
// Map to database.Reference expected by BuildSignupTx
89+
refPrices := make([]database.ReferencePrice, 0, len(plans))
90+
for _, p := range plans {
91+
refPrices = append(refPrices, database.ReferencePrice{
92+
Duration: p.Duration,
93+
Price: p.Price,
94+
})
95+
}
96+
refRegions := make([]database.ReferenceRegion, 0, len(regions))
97+
for _, r := range regions {
98+
refRegions = append(refRegions, database.ReferenceRegion{Name: r})
99+
}
100+
101+
return database.Reference{
102+
TxId: txid,
103+
OutputIdx: match.OutputIndex,
104+
Prices: refPrices,
105+
Regions: refRegions,
106+
}, nil
107+
}
108+
109+
func getDatumHash(m kugo.Match) string {
110+
return m.DatumHash
111+
}
112+
113+
func parseHex(s string) string {
114+
s = strings.TrimSpace(s)
115+
s = strings.TrimPrefix(s, "0x")
116+
s = strings.TrimPrefix(s, "0X")
117+
return s
118+
}

0 commit comments

Comments
 (0)