Skip to content

Commit d70bccd

Browse files
authored
Merge pull request #1004 from lightninglabs/example-price-oracle
Add example basic price oracle service
2 parents 1d9c445 + 6af97a7 commit d70bccd

File tree

9 files changed

+399
-0
lines changed

9 files changed

+399
-0
lines changed

.github/workflows/main.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ jobs:
107107
- name: compile code
108108
run: go install -v ./...
109109

110+
- name: Compile docs examples
111+
run: make build-docs-examples
112+
110113
########################
111114
# sample configuration check
112115
########################

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ cmd/tapd/tapd
1919
/itest/.minerlogs/*
2020
/itest/tapd-itest
2121

22+
/docs/examples/basic-price-oracle/basic-price-oracle
23+
2224
# Load test binaries and config
2325
/loadtest
2426
/loadtest.conf

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ build-itest:
101101
build-loadtest:
102102
CGO_ENABLED=0 $(GOTEST) -c -tags="$(LOADTEST_TAGS)" -o loadtest $(PKG)/itest/loadtest
103103

104+
build-docs-examples:
105+
@$(call print, "Building docs examples.")
106+
$(MAKE) -C ./docs/examples build
107+
104108
install:
105109
@$(call print, "Installing tapd and tapcli.")
106110
$(GOINSTALL) -tags="${tags}" -ldflags="$(RELEASE_LDFLAGS)" $(PKG)/cmd/tapd

docs/examples/Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
GOBUILD := GOEXPERIMENT=loopvar GO111MODULE=on go build -v
2+
3+
build:
4+
cd ./basic-price-oracle && $(GOBUILD)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module basic-price-oracle
2+
3+
go 1.22
4+
5+
toolchain go1.22.3
6+
7+
replace github.com/lightninglabs/taproot-assets => ../../../
8+
9+
require (
10+
github.com/lightninglabs/taproot-assets v0.0.0
11+
google.golang.org/grpc v1.59.0
12+
)
13+
14+
require (
15+
github.com/golang/protobuf v1.5.4 // indirect
16+
github.com/google/go-cmp v0.6.0 // indirect
17+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect
18+
golang.org/x/net v0.23.0 // indirect
19+
golang.org/x/sys v0.19.0 // indirect
20+
golang.org/x/text v0.14.0 // indirect
21+
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect
22+
google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect
23+
google.golang.org/protobuf v1.33.0 // indirect
24+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo=
2+
github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
3+
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
4+
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
5+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
6+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
7+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms=
8+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg=
9+
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
10+
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
11+
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
12+
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
13+
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
14+
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
15+
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA=
16+
google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI=
17+
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k=
18+
google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870=
19+
google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik=
20+
google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE=
21+
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
22+
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
23+
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
24+
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// This example demonstrates a basic RPC price oracle server that implements the
2+
// QueryRateTick RPC method. The server listens on localhost:8095 and returns a
3+
// rate tick for a given transaction type, subject asset, and payment asset. The
4+
// rate tick is the exchange rate between the subject asset and the payment
5+
// asset.
6+
package main
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"net"
12+
"time"
13+
14+
oraclerpc "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc"
15+
"google.golang.org/grpc"
16+
"google.golang.org/grpc/credentials/insecure"
17+
)
18+
19+
const (
20+
// serviceListenAddress is the listening address of the service.
21+
serviceListenAddress = "localhost:8095"
22+
)
23+
24+
// RpcPriceOracleServer is a basic example RPC price oracle server.
25+
type RpcPriceOracleServer struct {
26+
oraclerpc.UnimplementedPriceOracleServer
27+
}
28+
29+
// isSupportedSubjectAsset returns true if the given subject asset is supported
30+
// by the price oracle, and false otherwise.
31+
func isSupportedSubjectAsset(subjectAsset *oraclerpc.AssetSpecifier) bool {
32+
// Ensure that the subject asset is set.
33+
if subjectAsset == nil {
34+
return false
35+
}
36+
37+
// In this example we'll only support a single asset.
38+
assetIdStr := subjectAsset.GetAssetIdStr()
39+
supportedAssetId := "7b4336d33b019df9438e586f83c587ca00fa65602497b9" +
40+
"3ace193e9ce53b1a67"
41+
42+
return assetIdStr == supportedAssetId
43+
}
44+
45+
// getRateTick returns a rate tick for a given transaction type and subject
46+
// asset max amount.
47+
func getRateTick(transactionType oraclerpc.TransactionType,
48+
subjectAssetMaxAmount uint64) oraclerpc.RateTick {
49+
50+
// Determine the rate based on the transaction type.
51+
var rate uint64
52+
if transactionType == oraclerpc.TransactionType_PURCHASE {
53+
// The rate for a purchase transaction is 42,000 asset units per
54+
// mSAT.
55+
rate = 42_000
56+
} else {
57+
// The rate for a sale transaction is 40,000 asset units per
58+
// mSAT.
59+
rate = 40_000
60+
}
61+
62+
// Set the rate expiry to 5 minutes by default.
63+
expiry := time.Now().Add(5 * time.Minute).Unix()
64+
65+
// If the subject asset max amount is greater than 100,000, set the rate
66+
// expiry to 1 minute.
67+
if subjectAssetMaxAmount > 100_000 {
68+
expiry = time.Now().Add(1 * time.Minute).Unix()
69+
}
70+
71+
return oraclerpc.RateTick{
72+
Rate: rate,
73+
ExpiryTimestamp: uint64(expiry),
74+
}
75+
}
76+
77+
// QueryRateTick queries the rate tick for a given transaction type, subject
78+
// asset, and payment asset. The rate tick is the exchange rate between the
79+
// subject asset and the payment asset.
80+
//
81+
// Example use case:
82+
//
83+
// Alice is trying to pay an invoice by spending an asset. Alice therefore
84+
// requests that Bob (her asset channel counterparty) purchase the asset from
85+
// her. Bob's payment, in BTC, will pay the invoice.
86+
//
87+
// Alice requests a bid quote from Bob. Her request includes a rate tick
88+
// hint (ask). Alice get the rate tick hint by calling this endpoint. She sets:
89+
// - `SubjectAsset` to the asset she is trying to sell.
90+
// - `SubjectAssetMaxAmount` to the max channel asset outbound.
91+
// - `PaymentAsset` to BTC.
92+
// - `TransactionType` to SALE.
93+
// - `RateTickHint` to nil.
94+
//
95+
// Bob calls this endpoint to get the bid quote rate tick that he will send as a
96+
// response to Alice's request. He sets:
97+
// - `SubjectAsset` to the asset that Alice is trying to sell.
98+
// - `SubjectAssetMaxAmount` to the value given in Alice's quote request.
99+
// - `PaymentAsset` to BTC.
100+
// - `TransactionType` to PURCHASE.
101+
// - `RateTickHint` to the value given in Alice's quote request.
102+
func (p *RpcPriceOracleServer) QueryRateTick(_ context.Context,
103+
req *oraclerpc.QueryRateTickRequest) (
104+
*oraclerpc.QueryRateTickResponse, error) {
105+
106+
// Ensure that the payment asset is BTC. We only support BTC as the
107+
// payment asset in this example.
108+
if !oraclerpc.IsAssetBtc(req.PaymentAsset) {
109+
return &oraclerpc.QueryRateTickResponse{
110+
Result: &oraclerpc.QueryRateTickResponse_Error{
111+
Error: &oraclerpc.QueryRateTickErrResponse{
112+
Message: "unsupported payment asset, " +
113+
"only BTC is supported",
114+
},
115+
},
116+
}, nil
117+
}
118+
119+
// Ensure that the subject asset is set.
120+
if req.SubjectAsset == nil {
121+
return nil, fmt.Errorf("subject asset is not set")
122+
}
123+
124+
// Ensure that the subject asset is supported.
125+
if !isSupportedSubjectAsset(req.SubjectAsset) {
126+
return &oraclerpc.QueryRateTickResponse{
127+
Result: &oraclerpc.QueryRateTickResponse_Error{
128+
Error: &oraclerpc.QueryRateTickErrResponse{
129+
Message: "unsupported subject asset",
130+
},
131+
},
132+
}, nil
133+
}
134+
135+
// Determine which rate tick to return.
136+
var rateTick oraclerpc.RateTick
137+
138+
if req.RateTickHint != nil {
139+
// If a rate tick hint is provided, return it as the rate tick.
140+
// In doing so, we effectively accept the rate tick proposed by
141+
// our peer.
142+
rateTick.Rate = req.RateTickHint.Rate
143+
rateTick.ExpiryTimestamp = req.RateTickHint.ExpiryTimestamp
144+
} else {
145+
// If a rate tick hint is not provided, fetch a rate tick from
146+
// our internal system.
147+
rateTick = getRateTick(
148+
req.TransactionType, req.SubjectAssetMaxAmount,
149+
)
150+
}
151+
152+
return &oraclerpc.QueryRateTickResponse{
153+
Result: &oraclerpc.QueryRateTickResponse_Success{
154+
Success: &oraclerpc.QueryRateTickSuccessResponse{
155+
RateTick: &rateTick,
156+
},
157+
},
158+
}, nil
159+
}
160+
161+
// startService starts the given RPC server and blocks until the server is
162+
// shut down.
163+
func startService(grpcServer *grpc.Server) error {
164+
serviceAddr := fmt.Sprintf("rfqrpc://%s", serviceListenAddress)
165+
println("Starting RPC price oracle service at address: ", serviceAddr)
166+
167+
server := RpcPriceOracleServer{}
168+
oraclerpc.RegisterPriceOracleServer(grpcServer, &server)
169+
grpcListener, err := net.Listen("tcp", serviceListenAddress)
170+
if err != nil {
171+
return fmt.Errorf("RPC server unable to listen on %s",
172+
serviceListenAddress)
173+
}
174+
return grpcServer.Serve(grpcListener)
175+
}
176+
177+
func main() {
178+
// Start the mock RPC price oracle service.
179+
serverOpts := []grpc.ServerOption{
180+
grpc.Creds(insecure.NewCredentials()),
181+
}
182+
backendService := grpc.NewServer(serverOpts...)
183+
_ = startService(backendService)
184+
backendService.Stop()
185+
}

taprpc/priceoraclerpc/marshal.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package priceoraclerpc
2+
3+
import (
4+
"bytes"
5+
"encoding/hex"
6+
)
7+
8+
// IsAssetBtc is a helper function that returns true if the given asset
9+
// specifier represents BTC, and false otherwise.
10+
func IsAssetBtc(assetSpecifier *AssetSpecifier) bool {
11+
// An unset asset specifier does not represent BTC.
12+
if assetSpecifier == nil {
13+
return false
14+
}
15+
16+
// Verify that the asset specifier has a valid asset ID (either bytes or
17+
// string). The asset ID must be all zeros for the asset specifier to
18+
// represent BTC.
19+
assetIdBytes := assetSpecifier.GetAssetId()
20+
assetIdStr := assetSpecifier.GetAssetIdStr()
21+
22+
if len(assetIdBytes) != 32 && assetIdStr == "" {
23+
return false
24+
}
25+
26+
var assetId [32]byte
27+
copy(assetId[:], assetIdBytes)
28+
29+
var zeroAssetId [32]byte
30+
zeroAssetHexStr := hex.EncodeToString(zeroAssetId[:])
31+
32+
isAssetIdZero := bytes.Equal(assetId[:], zeroAssetId[:]) ||
33+
assetIdStr == zeroAssetHexStr
34+
35+
// Ensure that the asset specifier does not have any group key related
36+
// fields set. When specifying BTC, the group key fields must be unset.
37+
groupKeySet := assetSpecifier.GetGroupKey() != nil ||
38+
assetSpecifier.GetGroupKeyStr() != ""
39+
40+
return isAssetIdZero && !groupKeySet
41+
}

0 commit comments

Comments
 (0)