diff --git a/tokens/README.md b/tokens/README.md index 57a2787..381ca13 100644 --- a/tokens/README.md +++ b/tokens/README.md @@ -6,9 +6,9 @@ SPDX-License-Identifier: Apache-2.0 The **Token SDK Sample** demonstrates how to: -- Build a simple token-based application using the [Token SDK](https://github.com/hyperledger-labs/fabric-token-sdk). +- Build a simple token-based application using the [Token SDK](https://github.com/LFDT-Panurus/panurus). - Connect the application to both [Fabric-X](https://github.com/hyperledger/fabric-x) and classic [Fabric](https://github.com/hyperledger/fabric) networks. -- Issue and transfer tokens via a REST API. +- Issue, transfer, redeem and lock (HTLC) tokens via a REST API. ## Table of Contents @@ -32,6 +32,7 @@ The **Token SDK Sample** demonstrates how to: - [Interacting with the Application](#interacting-with-the-application) - [Example: Issue tokens](#example-issue-tokens) - [Example: Transfer tokens](#example-transfer-tokens) + - [Example: HTLC lock, claim and reclaim](#example-htlc-lock-claim-and-reclaim) - [Teardown and cleanup](#teardown-and-cleanup) - [Development](#development) - [Debug mode](#debug-mode) @@ -41,7 +42,7 @@ The **Token SDK Sample** demonstrates how to: ## About the Sample -This demo provides a set of services exposing REST APIs that integrate with the [Token SDK](https://github.com/hyperledger-labs/fabric-token-sdk) +This demo provides a set of services exposing REST APIs that integrate with the [Token SDK](https://github.com/LFDT-Panurus/panurus) to issue, transfer, and redeem tokens backed by a **Hyperledger Fabric(x)** network for validation and settlement. Together, these services form a _Layer 2 network_ capable of transacting privately among participants. @@ -49,7 +50,7 @@ The ledger data does not reveal balances, transaction amounts, or participant id Tokens are represented as UTXOs owned by pseudonymous keys, with details hidden through **Zero-Knowledge Proofs (ZKPs)**. The application follows the Fabric-X programming model, where business parties directly endorse transactions—rather than Fabric peers executing chaincode. -Note that the [Token SDK](https://github.com/hyperledger-labs/fabric-token-sdk) builds on top of the [Fabric Smart Client (FSC)](https://github.com/hyperledger-labs/fabric-smart-client), a framework to build distributed applications for Fabric(x). +Note that the [Token SDK](https://github.com/LFDT-Panurus/panurus) builds on top of the [Fabric Smart Client (FSC)](https://github.com/hyperledger-labs/fabric-smart-client), a framework to build distributed applications for Fabric(x). This sample helps you get familiar with Token SDK features and serves as a starting point for your own proof of concept. @@ -284,6 +285,52 @@ curl -X GET http://localhost:9600/owner/accounts/dan/transactions | jq curl -X GET http://localhost:9500/owner/accounts/alice/transactions | jq ``` +## Example: HTLC lock, claim and reclaim + +A hash time-locked contract (HTLC) lets `alice` lock tokens so that `dan` can only take them by revealing a secret +(the _pre-image_) before a deadline. If `dan` does not claim in time, `alice` can reclaim the tokens after the deadline. +It is the building block for atomic swaps between parties that do not trust each other. + +Hashes and pre-images are exchanged as standard base64 strings. `deadline` is the number of seconds from now. +On a node with more than one token management service you can select one with an optional +`"tmsId": {"network": "default", "channel": "mychannel", "namespace": "token_namespace"}` (the channel is `arma` on Fabric-X). + +`alice` locks `20 TOK` for `dan` for one hour. The node generates the pre-image and returns it with its SHA-256 hash; +`alice` passes the pre-image to `dan` out of band. `dan` then claims the tokens: + +```bash +LOCK=$(curl -s http://localhost:9500/owner/accounts/alice/lock -d '{ + "amount": {"code": "TOK","value": 20}, + "counterparty": {"node": "owner2","account": "dan"}, + "deadline": 3600 +}') +echo "$LOCK" | jq +PREIMAGE=$(echo "$LOCK" | jq -r .payload.preimage) + +curl http://localhost:9600/owner/accounts/dan/claim -d '{"preimage": "'"$PREIMAGE"'"}' +curl http://localhost:9600/owner/accounts/dan | jq +``` + +If nobody claims, `alice` can reclaim after the deadline, identifying the lock by its hash: + +```bash +LOCK=$(curl -s http://localhost:9500/owner/accounts/alice/lock -d '{ + "amount": {"code": "TOK","value": 20}, + "counterparty": {"node": "owner2","account": "dan"}, + "deadline": 30 +}') +HASH=$(echo "$LOCK" | jq -r .payload.hash) +sleep 35 +curl http://localhost:9500/owner/accounts/alice/reclaim -d '{"hash": "'"$HASH"'"}' +``` + +If you already hold a secret, pass its SHA-256 digest as `hash` in the lock request. The response then contains no pre-image: + +```bash +SECRET=$(openssl rand -base64 24) # the pre-image +HASH=$(printf '%s' "$SECRET" | openssl base64 -d -A | openssl dgst -sha256 -binary | openssl base64 -A) +``` + ## Teardown and cleanup To fully stop and delete the state of the application, run: diff --git a/tokens/owner/routes/routes.gen.go b/tokens/owner/routes/routes.gen.go index 7a467c1..e0b5eb5 100644 --- a/tokens/owner/routes/routes.gen.go +++ b/tokens/owner/routes/routes.gen.go @@ -2,7 +2,7 @@ // Package routes provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. package routes import ( @@ -34,6 +34,15 @@ type Amount struct { Value uint64 `json:"value"` } +// ClaimRequest Instructions to claim locked tokens by revealing the pre-image +type ClaimRequest struct { + // Preimage Base64 encoded pre-image whose SHA-256 hash matches the lock + Preimage []byte `json:"preimage"` + + // TmsId Identifies a token management service (TMS) on the node. Omit it to use the node's default TMS. + TmsId *TMSID `json:"tmsId,omitempty"` +} + // Counterparty The counterparty in a Transfer or Issuance transaction. type Counterparty struct { Account string `json:"account"` @@ -51,15 +60,61 @@ type Error struct { Payload string `json:"payload"` } +// LockRequest Instructions to lock tokens in a hash time-locked contract (HTLC) +type LockRequest struct { + // Amount The amount to issue, transfer or redeem. + Amount Amount `json:"amount"` + + // Counterparty The counterparty in a Transfer or Issuance transaction. + Counterparty Counterparty `json:"counterparty"` + + // Deadline Seconds from now after which the sender can reclaim the tokens if nobody has claimed them + Deadline int64 `json:"deadline"` + + // Hash Base64 (standard, padded) SHA-256 digest (32 bytes) of a pre-image you already hold, e.g. when the other side of a cross-network swap chose the secret. Omit it to let the node generate a pre-image and its hash. + Hash *[]byte `json:"hash,omitempty"` + + // TmsId Identifies a token management service (TMS) on the node. Omit it to use the node's default TMS. + TmsId *TMSID `json:"tmsId,omitempty"` +} + +// LockResult The transaction id of the lock, plus the pre-image and hash that unlock it +type LockResult struct { + // Hash Base64 encoded SHA-256 hash of the pre-image, as recorded in the lock + Hash []byte `json:"hash"` + + // Preimage Base64 encoded secret. Only present when the node generated it (no hash was supplied in the request). + Preimage *[]byte `json:"preimage,omitempty"` + + // TxId Transaction id + TxId string `json:"txId"` +} + +// ReclaimRequest Instructions to reclaim an expired lock +type ReclaimRequest struct { + // Hash Base64 encoded hash of the lock to reclaim, as returned by the lock operation + Hash []byte `json:"hash"` + + // TmsId Identifies a token management service (TMS) on the node. Omit it to use the node's default TMS. + TmsId *TMSID `json:"tmsId,omitempty"` +} + // RedeemRequest Instructions to redeem tokens from an account type RedeemRequest struct { // Amount The amount to issue, transfer or redeem. Amount Amount `json:"amount"` - // Message optional message that will be visible to the auditor + // Message optional message that will be visible to the issuer Message *string `json:"message,omitempty"` } +// TMSID Identifies a token management service (TMS) on the node. Omit it to use the node's default TMS. +type TMSID struct { + Channel string `json:"channel"` + Namespace string `json:"namespace"` + Network string `json:"network"` +} + // TransactionRecord A transaction type TransactionRecord struct { // Amount The amount to issue, transfer or redeem. @@ -116,6 +171,14 @@ type AccountsSuccess struct { Payload []Account `json:"payload"` } +// ClaimSuccess defines model for ClaimSuccess. +type ClaimSuccess struct { + Message string `json:"message"` + + // Payload Transaction id + Payload string `json:"payload"` +} + // ErrorResponse defines model for ErrorResponse. type ErrorResponse = Error @@ -125,6 +188,22 @@ type HealthSuccess struct { Message string `json:"message"` } +// LockSuccess defines model for LockSuccess. +type LockSuccess struct { + Message string `json:"message"` + + // Payload The transaction id of the lock, plus the pre-image and hash that unlock it + Payload LockResult `json:"payload"` +} + +// ReclaimSuccess defines model for ReclaimSuccess. +type ReclaimSuccess struct { + Message string `json:"message"` + + // Payload Transaction id + Payload string `json:"payload"` +} + // RedeemSuccess defines model for RedeemSuccess. type RedeemSuccess struct { Message string `json:"message"` @@ -152,6 +231,15 @@ type OwnerAccountParams struct { Code *Code `form:"code,omitempty" json:"code,omitempty"` } +// ClaimJSONRequestBody defines body for Claim for application/json ContentType. +type ClaimJSONRequestBody = ClaimRequest + +// LockJSONRequestBody defines body for Lock for application/json ContentType. +type LockJSONRequestBody = LockRequest + +// ReclaimJSONRequestBody defines body for Reclaim for application/json ContentType. +type ReclaimJSONRequestBody = ReclaimRequest + // RedeemJSONRequestBody defines body for Redeem for application/json ContentType. type RedeemJSONRequestBody = RedeemRequest @@ -169,6 +257,15 @@ type ServerInterface interface { // Get an account and its balances of each token type // (GET /owner/accounts/{id}) OwnerAccount(w http.ResponseWriter, r *http.Request, id Id, params OwnerAccountParams) + // Claim tokens that were locked for this account + // (POST /owner/accounts/{id}/claim) + Claim(w http.ResponseWriter, r *http.Request, id Id) + // Lock tokens in a hash time-locked contract (HTLC) + // (POST /owner/accounts/{id}/lock) + Lock(w http.ResponseWriter, r *http.Request, id Id) + // Reclaim expired locked tokens + // (POST /owner/accounts/{id}/reclaim) + Reclaim(w http.ResponseWriter, r *http.Request, id Id) // Redeem (burn) tokens // (POST /owner/accounts/{id}/redeem) Redeem(w http.ResponseWriter, r *http.Request, id Id) @@ -256,6 +353,81 @@ func (siw *ServerInterfaceWrapper) OwnerAccount(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } +// Claim operation middleware +func (siw *ServerInterfaceWrapper) Claim(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id Id + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Claim(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Lock operation middleware +func (siw *ServerInterfaceWrapper) Lock(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id Id + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Lock(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// Reclaim operation middleware +func (siw *ServerInterfaceWrapper) Reclaim(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id Id + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.Reclaim(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // Redeem operation middleware func (siw *ServerInterfaceWrapper) Redeem(w http.ResponseWriter, r *http.Request) { @@ -468,6 +640,9 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc("GET "+options.BaseURL+"/healthz", wrapper.Healthz) m.HandleFunc("GET "+options.BaseURL+"/owner/accounts", wrapper.OwnerAccounts) m.HandleFunc("GET "+options.BaseURL+"/owner/accounts/{id}", wrapper.OwnerAccount) + m.HandleFunc("POST "+options.BaseURL+"/owner/accounts/{id}/claim", wrapper.Claim) + m.HandleFunc("POST "+options.BaseURL+"/owner/accounts/{id}/lock", wrapper.Lock) + m.HandleFunc("POST "+options.BaseURL+"/owner/accounts/{id}/reclaim", wrapper.Reclaim) m.HandleFunc("POST "+options.BaseURL+"/owner/accounts/{id}/redeem", wrapper.Redeem) m.HandleFunc("GET "+options.BaseURL+"/owner/accounts/{id}/transactions", wrapper.OwnerTransactions) m.HandleFunc("POST "+options.BaseURL+"/owner/accounts/{id}/transfer", wrapper.Transfer) @@ -488,6 +663,13 @@ type AccountsSuccessJSONResponse struct { Payload []Account `json:"payload"` } +type ClaimSuccessJSONResponse struct { + Message string `json:"message"` + + // Payload Transaction id + Payload string `json:"payload"` +} + type ErrorResponseJSONResponse Error type HealthSuccessJSONResponse struct { @@ -495,6 +677,20 @@ type HealthSuccessJSONResponse struct { Message string `json:"message"` } +type LockSuccessJSONResponse struct { + Message string `json:"message"` + + // Payload The transaction id of the lock, plus the pre-image and hash that unlock it + Payload LockResult `json:"payload"` +} + +type ReclaimSuccessJSONResponse struct { + Message string `json:"message"` + + // Payload Transaction id + Payload string `json:"payload"` +} + type RedeemSuccessJSONResponse struct { Message string `json:"message"` @@ -597,6 +793,96 @@ func (response OwnerAccountdefaultJSONResponse) VisitOwnerAccountResponse(w http return json.NewEncoder(w).Encode(response.Body) } +type ClaimRequestObject struct { + Id Id `json:"id"` + Body *ClaimJSONRequestBody +} + +type ClaimResponseObject interface { + VisitClaimResponse(w http.ResponseWriter) error +} + +type Claim200JSONResponse struct{ ClaimSuccessJSONResponse } + +func (response Claim200JSONResponse) VisitClaimResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ClaimdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ClaimdefaultJSONResponse) VisitClaimResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type LockRequestObject struct { + Id Id `json:"id"` + Body *LockJSONRequestBody +} + +type LockResponseObject interface { + VisitLockResponse(w http.ResponseWriter) error +} + +type Lock200JSONResponse struct{ LockSuccessJSONResponse } + +func (response Lock200JSONResponse) VisitLockResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type LockdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response LockdefaultJSONResponse) VisitLockResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type ReclaimRequestObject struct { + Id Id `json:"id"` + Body *ReclaimJSONRequestBody +} + +type ReclaimResponseObject interface { + VisitReclaimResponse(w http.ResponseWriter) error +} + +type Reclaim200JSONResponse struct{ ReclaimSuccessJSONResponse } + +func (response Reclaim200JSONResponse) VisitReclaimResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ReclaimdefaultJSONResponse struct { + Body Error + StatusCode int +} + +func (response ReclaimdefaultJSONResponse) VisitReclaimResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + type RedeemRequestObject struct { Id Id `json:"id"` Body *RedeemJSONRequestBody @@ -724,6 +1010,15 @@ type StrictServerInterface interface { // Get an account and its balances of each token type // (GET /owner/accounts/{id}) OwnerAccount(ctx context.Context, request OwnerAccountRequestObject) (OwnerAccountResponseObject, error) + // Claim tokens that were locked for this account + // (POST /owner/accounts/{id}/claim) + Claim(ctx context.Context, request ClaimRequestObject) (ClaimResponseObject, error) + // Lock tokens in a hash time-locked contract (HTLC) + // (POST /owner/accounts/{id}/lock) + Lock(ctx context.Context, request LockRequestObject) (LockResponseObject, error) + // Reclaim expired locked tokens + // (POST /owner/accounts/{id}/reclaim) + Reclaim(ctx context.Context, request ReclaimRequestObject) (ReclaimResponseObject, error) // Redeem (burn) tokens // (POST /owner/accounts/{id}/redeem) Redeem(ctx context.Context, request RedeemRequestObject) (RedeemResponseObject, error) @@ -842,6 +1137,105 @@ func (sh *strictHandler) OwnerAccount(w http.ResponseWriter, r *http.Request, id } } +// Claim operation middleware +func (sh *strictHandler) Claim(w http.ResponseWriter, r *http.Request, id Id) { + var request ClaimRequestObject + + request.Id = id + + var body ClaimJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Claim(ctx, request.(ClaimRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Claim") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ClaimResponseObject); ok { + if err := validResponse.VisitClaimResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Lock operation middleware +func (sh *strictHandler) Lock(w http.ResponseWriter, r *http.Request, id Id) { + var request LockRequestObject + + request.Id = id + + var body LockJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Lock(ctx, request.(LockRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Lock") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LockResponseObject); ok { + if err := validResponse.VisitLockResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// Reclaim operation middleware +func (sh *strictHandler) Reclaim(w http.ResponseWriter, r *http.Request, id Id) { + var request ReclaimRequestObject + + request.Id = id + + var body ReclaimJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.Reclaim(ctx, request.(ReclaimRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "Reclaim") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ReclaimResponseObject); ok { + if err := validResponse.VisitReclaimResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // Redeem operation middleware func (sh *strictHandler) Redeem(w http.ResponseWriter, r *http.Request, id Id) { var request RedeemRequestObject diff --git a/tokens/owner/routes/routes.go b/tokens/owner/routes/routes.go index 41ee5af..36b54d1 100644 --- a/tokens/owner/routes/routes.go +++ b/tokens/owner/routes/routes.go @@ -8,8 +8,13 @@ package routes import ( "context" + "crypto/sha256" "fmt" + "math" + "net/http" + "time" + "github.com/LFDT-Panurus/panurus/token" "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver" "github.com/hyperledger/fabric-samples/token-sdk/owner/service" ) @@ -136,6 +141,94 @@ func (s Server) Transfer(ctx context.Context, request TransferRequestObject) (Tr }}, nil } +// toTMSID converts the optional TMS identifier of a request. A nil result selects the node's default TMS. +func toTMSID(id *TMSID) *token.TMSID { + if id == nil { + return nil + } + return &token.TMSID{Network: id.Network, Channel: id.Channel, Namespace: id.Namespace} +} + +// toDeadline converts seconds to a duration. Zero would silently select the Token SDK default (one hour), +// so only positive values that fit in a time.Duration are accepted. +func toDeadline(seconds int64) (time.Duration, bool) { + if seconds < 1 || seconds > math.MaxInt64/int64(time.Second) { + return 0, false + } + return time.Duration(seconds) * time.Second, true +} + +func badRequest(message string) Error { + return Error{Message: "bad request", Payload: message} +} + +// Lock tokens in a hash time-locked contract (HTLC) +// (POST /owner/accounts/{id}/lock) +func (s Server) Lock(ctx context.Context, request LockRequestObject) (LockResponseObject, error) { + body := request.Body + deadline, ok := toDeadline(body.Deadline) + if !ok { + return LockdefaultJSONResponse{Body: badRequest("deadline must be a positive number of seconds"), StatusCode: http.StatusBadRequest}, nil + } + var hash []byte + if body.Hash != nil { + hash = *body.Hash + if len(hash) != sha256.Size { + return LockdefaultJSONResponse{Body: badRequest(fmt.Sprintf("hash must be a %d byte SHA-256 digest", sha256.Size)), StatusCode: http.StatusBadRequest}, nil + } + } + + res, err := s.fsc.Lock(ctx, body.Amount.Code, body.Amount.Value, request.Id, body.Counterparty.Account, body.Counterparty.Node, deadline, hash, toTMSID(body.TmsId)) + if err != nil { + return nil, err + } + + payload := LockResult{TxId: res.TxID, Hash: res.Hash} + if len(res.PreImage) > 0 { + payload.Preimage = &res.PreImage + } + return Lock200JSONResponse{LockSuccessJSONResponse{ + Message: "ok", + Payload: payload, + }}, nil +} + +// Claim locked tokens by revealing the pre-image +// (POST /owner/accounts/{id}/claim) +func (s Server) Claim(ctx context.Context, request ClaimRequestObject) (ClaimResponseObject, error) { + if len(request.Body.Preimage) == 0 { + return ClaimdefaultJSONResponse{Body: badRequest("preimage is required"), StatusCode: http.StatusBadRequest}, nil + } + + txID, err := s.fsc.Claim(ctx, request.Id, request.Body.Preimage, toTMSID(request.Body.TmsId)) + if err != nil { + return nil, err + } + + return Claim200JSONResponse{ClaimSuccessJSONResponse{ + Message: "ok", + Payload: txID, + }}, nil +} + +// Reclaim expired locked tokens +// (POST /owner/accounts/{id}/reclaim) +func (s Server) Reclaim(ctx context.Context, request ReclaimRequestObject) (ReclaimResponseObject, error) { + if len(request.Body.Hash) == 0 { + return ReclaimdefaultJSONResponse{Body: badRequest("hash is required"), StatusCode: http.StatusBadRequest}, nil + } + + txID, err := s.fsc.Reclaim(ctx, request.Id, request.Body.Hash, toTMSID(request.Body.TmsId)) + if err != nil { + return nil, err + } + + return Reclaim200JSONResponse{ReclaimSuccessJSONResponse{ + Message: "ok", + Payload: txID, + }}, nil +} + // Returns 200 if the service is healthy // (GET /healthz) func (s Server) Healthz(ctx context.Context, request HealthzRequestObject) (HealthzResponseObject, error) { diff --git a/tokens/owner/service/htlc_claim.go b/tokens/owner/service/htlc_claim.go index 5214ee2..2986249 100644 --- a/tokens/owner/service/htlc_claim.go +++ b/tokens/owner/service/htlc_claim.go @@ -78,7 +78,7 @@ func (f *ClaimViewFactory) NewView(in []byte) (view.View, error) { } // Claim claims a previously locked token using its pre-image, crediting the given wallet. -func (f FabricSmartClient) Claim(ctx context.Context, wallet string, preImage []byte) (txID string, err error) { +func (f FabricSmartClient) Claim(ctx context.Context, wallet string, preImage []byte, tmsID *token.TMSID) (txID string, err error) { logger.Infof("going to claim htlc token into [%s]", wallet) mgr, err := viewregistry.GetManager(f.node) if err != nil { @@ -86,6 +86,7 @@ func (f FabricSmartClient) Claim(ctx context.Context, wallet string, preImage [] } res, err := mgr.InitiateView(ctx, &ClaimView{ ClaimOptions: &ClaimOptions{ + TMSID: tmsID, Wallet: wallet, PreImage: preImage, }, diff --git a/tokens/owner/service/htlc_lock.go b/tokens/owner/service/htlc_lock.go index d5be4a2..83e7f31 100644 --- a/tokens/owner/service/htlc_lock.go +++ b/tokens/owner/service/htlc_lock.go @@ -166,7 +166,7 @@ func (a *LockAcceptView) Call(vctx view.Context) (any, error) { // Lock locks an amount of a certain token type into an HTLC script, returning the pre-image the // locker must pass to the claimer out of band, and the hash the claimer/reclaimer will reference. -func (f FabricSmartClient) Lock(ctx context.Context, tokenType string, quantity uint64, sender string, recipient string, recipientNode string, deadline time.Duration, hash []byte) (LockResult, error) { +func (f FabricSmartClient) Lock(ctx context.Context, tokenType string, quantity uint64, sender string, recipient string, recipientNode string, deadline time.Duration, hash []byte, tmsID *token.TMSID) (LockResult, error) { logger.Infof("going to lock %d %s from [%s] for [%s] on [%s]", quantity, tokenType, sender, recipient, recipientNode) mgr, err := viewregistry.GetManager(f.node) if err != nil { @@ -174,6 +174,7 @@ func (f FabricSmartClient) Lock(ctx context.Context, tokenType string, quantity } res, err := mgr.InitiateView(ctx, &LockView{ LockOptions: &LockOptions{ + TMSID: tmsID, Wallet: sender, TokenType: tokenType, Quantity: quantity, diff --git a/tokens/owner/service/htlc_reclaim.go b/tokens/owner/service/htlc_reclaim.go index 2c516cc..3ff46d7 100644 --- a/tokens/owner/service/htlc_reclaim.go +++ b/tokens/owner/service/htlc_reclaim.go @@ -75,7 +75,7 @@ func (f *ReclaimViewFactory) NewView(in []byte) (view.View, error) { } // Reclaim reclaims a single expired locked token identified by hash, crediting it back to the given wallet. -func (f FabricSmartClient) Reclaim(ctx context.Context, wallet string, hash []byte) (txID string, err error) { +func (f FabricSmartClient) Reclaim(ctx context.Context, wallet string, hash []byte, tmsID *token.TMSID) (txID string, err error) { logger.Infof("going to reclaim htlc token into [%s]", wallet) mgr, err := viewregistry.GetManager(f.node) if err != nil { @@ -83,6 +83,7 @@ func (f FabricSmartClient) Reclaim(ctx context.Context, wallet string, hash []by } res, err := mgr.InitiateView(ctx, &ReclaimView{ ReclaimOptions: &ReclaimOptions{ + TMSID: tmsID, Wallet: wallet, Hash: hash, }, diff --git a/tokens/scripts/test.sh b/tokens/scripts/test.sh index 39b46cf..620b85e 100755 --- a/tokens/scripts/test.sh +++ b/tokens/scripts/test.sh @@ -19,6 +19,8 @@ function print_section_header() { function cleanup() { local exit_code=$? local failed_cmd="$BASH_COMMAND" + # Command substitutions inherit the ERR trap; only the main shell should tear down the network + [[ $BASHPID == "$$" ]] || exit "$exit_code" trap - INT ERR set +e stop_network @@ -131,6 +133,117 @@ function run_test() { curl_with_retry GET http://localhost:9500/owner/accounts/alice | jq } +## Token SDK channel of the default TMS for the selected platform +function tms_channel() { + case "$PLATFORM" in + fabric3) echo "mychannel" ;; + *) echo "arma" ;; + esac +} + +## Print the balance of an account: get_balance +function get_balance() { + curl -sSf "http://localhost:$1/owner/accounts/$2?code=$3" | jq -er '.payload.balance[0].value // 0' +} + +## base64(SHA-256(base64 decoded input)) +function sha256_b64() { + printf '%s' "$1" | openssl base64 -d -A | openssl dgst -sha256 -binary | openssl base64 -A +} + +## assert_eq +function assert_eq() { + if [[ "$1" != "$2" ]]; then + echo "FAIL: $3: expected '$1', got '$2'" >&2 + return 1 + fi + echo "OK: $3 ($2)" +} + +## Poll until an account holds the expected balance (finality is asynchronous on the non-initiating node) +## wait_for_balance +function wait_for_balance() { + local port="$1" account="$2" code="$3" expected="$4" what="$5" + local attempts="${BALANCE_MAX_ATTEMPTS:-15}" actual="" i + for ((i = 1; i <= attempts; i++)); do + actual=$(get_balance "$port" "$account" "$code") + if [[ "$actual" == "$expected" ]]; then + echo "OK: ${what}: ${account} holds ${actual} ${code}" + return 0 + fi + sleep 2 + done + echo "FAIL: ${what}: expected ${account} to hold ${expected} ${code}, got ${actual}" >&2 + return 1 +} + +## Succeeds only if the server rejects the request (HTTP status >= 400). +## Plain curl on purpose: curl_with_retry would retry the request. +## expect_http_error [curl args...] +function expect_http_error() { + local status + status=$(curl -s -o /dev/null -w '%{http_code}' -X "$@") || true # 000 on a connection error, which is not a rejection + if (( 10#${status:-0} >= 400 )); then + echo "OK: request was rejected with HTTP ${status} as expected" + return 0 + fi + echo "FAIL: expected the request to be rejected, got HTTP ${status}" >&2 + return 1 +} + +## Run the HTLC (hash time-locked contract) tests: lock, claim and reclaim +function run_htlc_test() { + print_section_header "Run HTLC tests" + + local amount=20 deadline="${HTLC_DEADLINE:-10}" + local tms + tms='{"network": "default", "channel": "'"$(tms_channel)"'", "namespace": "token_namespace"}' + local alice_before dan_before lock preimage hash secret secret_hash + + # 1. Node generated pre-image and an explicit TMS: alice locks for dan, dan claims + alice_before=$(get_balance 9500 alice TOK) + dan_before=$(get_balance 9600 dan TOK) + lock=$(curl_with_retry POST http://localhost:9500/owner/accounts/alice/lock -d '{ + "amount": {"code": "TOK", "value": '"$amount"'}, + "counterparty": {"node": "owner2", "account": "dan"}, + "deadline": 3600, + "tmsId": '"$tms"' + }') + preimage=$(jq -er '.payload.preimage' <<<"$lock") + hash=$(jq -er '.payload.hash' <<<"$lock") + assert_eq "$(sha256_b64 "$preimage")" "$hash" "lock returned the SHA-256 of the pre-image" + curl_with_retry POST http://localhost:9600/owner/accounts/dan/claim -d '{ + "preimage": "'"$preimage"'", + "tmsId": '"$tms"' + }' >/dev/null + wait_for_balance 9600 dan TOK $((dan_before + amount)) "claim credited dan" + wait_for_balance 9500 alice TOK $((alice_before - amount)) "lock debited alice" + + # 2. Caller supplied hash and the default TMS: alice locks for dan with a short deadline, nobody claims + # in time, alice reclaims, and dan's late claim is rejected + secret=$(openssl rand -base64 24) + secret_hash=$(sha256_b64 "$secret") + alice_before=$(get_balance 9500 alice TOK) + lock=$(curl_with_retry POST http://localhost:9500/owner/accounts/alice/lock -d '{ + "amount": {"code": "TOK", "value": '"$amount"'}, + "counterparty": {"node": "owner2", "account": "dan"}, + "deadline": '"$deadline"', + "hash": "'"$secret_hash"'" + }') + if ! jq -e --arg h "$secret_hash" '.payload.hash == $h and (.payload | has("preimage") | not)' <<<"$lock" >/dev/null; then + echo "FAIL: expected the lock to echo the supplied hash and return no pre-image, got: $lock" >&2 + return 1 + fi + wait_for_balance 9500 alice TOK $((alice_before - amount)) "second lock debited alice" + echo "Waiting $((deadline + 5))s for the lock to expire..." + sleep $((deadline + 5)) + curl_with_retry POST http://localhost:9500/owner/accounts/alice/reclaim -d '{"hash": "'"$secret_hash"'"}' >/dev/null + wait_for_balance 9500 alice TOK "$alice_before" "reclaim restored alice" + dan_before=$(get_balance 9600 dan TOK) + expect_http_error POST http://localhost:9600/owner/accounts/dan/claim -d '{"preimage": "'"$secret"'"}' + wait_for_balance 9600 dan TOK "$dan_before" "rejected claim left dan unchanged" +} + # Script Start set -eE set -o pipefail @@ -147,4 +260,10 @@ if [[ "$PLATFORM" == "fabricx" || "$PLATFORM" == "xdev" ]]; then init_fabricx fi run_test +# The HTLC tests run on fabric3 by default; set HTLC_TEST=1 to run them on other platforms too +if [[ "$PLATFORM" == "fabric3" || "${HTLC_TEST:-}" == "1" ]]; then + run_htlc_test +else + echo "Skipping HTLC tests on ${PLATFORM} (set HTLC_TEST=1 to run them)" +fi stop_network \ No newline at end of file diff --git a/tokens/swagger.yaml b/tokens/swagger.yaml index 4e2ea9c..6625632 100644 --- a/tokens/swagger.yaml +++ b/tokens/swagger.yaml @@ -10,6 +10,10 @@ info: To try it out, let the Issuer issue some funds to one of the users. Watch the balances get updated and see the transaction history. Transfer it to another account, or redeem the tokens to destroy them. + + Tokens can also be locked in a hash time-locked contract (HTLC): the recipient claims them by revealing a + secret pre-image before the deadline, otherwise the sender can reclaim them after it. Hashes and pre-images + are base64 strings. servers: # - url: http://localhost:9500/ # description: auditor @@ -216,6 +220,90 @@ paths: default: $ref: "#/components/responses/ErrorResponse" + /owner/accounts/{id}/lock: + servers: + - url: http://localhost:9500/ + description: alice and bob + - url: http://localhost:9600/ + description: carlos and dan + summary: Lock tokens in a hash time-locked contract (HTLC) + post: + tags: + - owner + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LockRequest" + responses: + "200": + $ref: "#/components/responses/LockSuccess" + default: + $ref: "#/components/responses/ErrorResponse" + operationId: lock + summary: Lock tokens in a hash time-locked contract (HTLC) + description: | + Locks tokens owned by this account so that the counterparty can claim them by revealing a secret + pre-image before the deadline, or the sender can reclaim them after the deadline. + + If no hash is given, the node generates a random pre-image and returns it together with its + SHA-256 hash. Hand the pre-image to the counterparty out of band. + + /owner/accounts/{id}/claim: + servers: + - url: http://localhost:9500/ + description: alice and bob + - url: http://localhost:9600/ + description: carlos and dan + summary: Claim tokens that were locked for this account + post: + tags: + - owner + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ClaimRequest" + responses: + "200": + $ref: "#/components/responses/ClaimSuccess" + default: + $ref: "#/components/responses/ErrorResponse" + operationId: claim + summary: Claim tokens that were locked for this account + + /owner/accounts/{id}/reclaim: + servers: + - url: http://localhost:9500/ + description: alice and bob + - url: http://localhost:9600/ + description: carlos and dan + summary: Reclaim expired locked tokens + post: + tags: + - owner + parameters: + - $ref: "#/components/parameters/id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReclaimRequest" + responses: + "200": + $ref: "#/components/responses/ReclaimSuccess" + default: + $ref: "#/components/responses/ErrorResponse" + operationId: reclaim + summary: Reclaim expired locked tokens + # Operations /healthz: get: @@ -353,6 +441,52 @@ components: description: ok example: message: ok + LockSuccess: + description: Success response + content: + application/json: + schema: + type: object + required: + - message + - payload + properties: + message: + type: string + payload: + $ref: "#/components/schemas/LockResult" + ClaimSuccess: + description: Success response + content: + application/json: + schema: + type: object + required: + - message + - payload + properties: + message: + type: string + payload: + type: string + description: Transaction id + example: 7c26ed6e5e05f7de81a82691b66b1069d1507d9edf3c7b78ea1fa98557ee57b4 + ReclaimSuccess: + description: Success response + content: + application/json: + schema: + type: object + required: + - message + - payload + properties: + message: + type: string + payload: + type: string + description: Transaction id + example: 7c26ed6e5e05f7de81a82691b66b1069d1507d9edf3c7b78ea1fa98557ee57b4 ErrorResponse: description: Error response @@ -490,6 +624,91 @@ components: message: description: optional message that will be visible to the issuer type: string + TMSID: + description: Identifies a token management service (TMS) on the node. Omit it to use the node's default TMS. + type: object + required: + - network + - channel + - namespace + properties: + network: + type: string + example: default + channel: + type: string + example: mychannel + namespace: + type: string + example: token_namespace + LockRequest: + description: Instructions to lock tokens in a hash time-locked contract (HTLC) + required: + - amount + - counterparty + - deadline + type: object + properties: + amount: + $ref: "#/components/schemas/Amount" + counterparty: + $ref: "#/components/schemas/Counterparty" + deadline: + description: Seconds from now after which the sender can reclaim the tokens if nobody has claimed them + type: integer + format: int64 + minimum: 1 + example: 3600 + hash: + description: >- + Base64 (standard, padded) SHA-256 digest (32 bytes) of a pre-image you already hold, e.g. when the other + side of a cross-network swap chose the secret. Omit it to let the node generate a pre-image and its hash. + type: string + format: byte + tmsId: + $ref: "#/components/schemas/TMSID" + ClaimRequest: + description: Instructions to claim locked tokens by revealing the pre-image + required: + - preimage + type: object + properties: + preimage: + description: Base64 encoded pre-image whose SHA-256 hash matches the lock + type: string + format: byte + tmsId: + $ref: "#/components/schemas/TMSID" + ReclaimRequest: + description: Instructions to reclaim an expired lock + required: + - hash + type: object + properties: + hash: + description: Base64 encoded hash of the lock to reclaim, as returned by the lock operation + type: string + format: byte + tmsId: + $ref: "#/components/schemas/TMSID" + LockResult: + description: The transaction id of the lock, plus the pre-image and hash that unlock it + required: + - txId + - hash + type: object + properties: + txId: + type: string + description: Transaction id + preimage: + description: Base64 encoded secret. Only present when the node generated it (no hash was supplied in the request). + type: string + format: byte + hash: + description: Base64 encoded SHA-256 hash of the pre-image, as recorded in the lock + type: string + format: byte Error: required: - message