diff --git a/api_assets_test.go b/api_assets_test.go index fa2b7c4..201690b 100644 --- a/api_assets_test.go +++ b/api_assets_test.go @@ -136,6 +136,13 @@ func testIntUtil(t *testing.T, fp string, got interface{}, want interface{}, opt } if !reflect.DeepEqual(got, want) { - t.Fatalf("expected %v got %v", want, got) + gotJSON, err := json.Marshal(got) + wantJSON, err := json.Marshal(want) + if err != nil { + t.Fatalf("\nexpected %v \ngot %v", want, got) + } + + t.Fatalf("\nexpected %s \ngot %s", string(wantJSON), string(gotJSON)) + } } diff --git a/api_block.go b/api_block.go index 3f1d7f9..c9fe45f 100644 --- a/api_block.go +++ b/api_block.go @@ -50,7 +50,7 @@ type Block struct { PreviousBlock string `json:"previous_block"` // Hash of the next block - NextBlock string `json:"next_block"` + NextBlock *string `json:"next_block"` // Number of block confirmations Confirmations int `json:"confirmations"` diff --git a/api_script_test.go b/api_script_test.go index 8f740e1..45b77df 100644 --- a/api_script_test.go +++ b/api_script_test.go @@ -3,7 +3,7 @@ package blockfrost_test import ( "context" "encoding/json" - "io/ioutil" + "os" "path/filepath" "reflect" "testing" @@ -25,7 +25,7 @@ func TestScriptUnmarshal(t *testing.T) { } func testStructGotWant(t *testing.T, fp string, got interface{}, want interface{}) { - bytes, err := ioutil.ReadFile(fp) + bytes, err := os.ReadFile(fp) if err != nil { t.Fatalf("failed to open json test file %s with err %v", fp, err) } diff --git a/api_transaction_test.go b/api_transaction_test.go index 3402d2e..b416ec4 100644 --- a/api_transaction_test.go +++ b/api_transaction_test.go @@ -276,11 +276,11 @@ func TestTransactionEvaluateUTXOsIntegration(t *testing.T) { additionalUtxoSet := blockfrost.AdditionalUtxoSet{ { - TxIn: blockfrost.TxIn{ + TxIn: blockfrost.AdditionalUtxoSetTxIn{ TxID: "ec6eb047f74e5412c116a819cdd43f1c27a29f2871241453019637b850461b43", Index: 0, }, - TxOut: blockfrost.TxOut{ + TxOut: blockfrost.AdditionalUtxoSetTxOut{ Address: "addr1qxvduldkktan65x4dg5gkfaaehc798pjg755yckuk5tjcedre5df3pzwwmyq946axfcejy5n4x0y99wqpgtp2gd0k09qgcyhcc", Value: blockfrost.Value{ Coins: "1300000000", diff --git a/api_transactions.go b/api_transactions.go index 3a32255..9a5d4d2 100644 --- a/api_transactions.go +++ b/api_transactions.go @@ -105,28 +105,31 @@ type TxAmount struct { Unit string `json:"unit"` } +type TransactionInput struct { + Address string `json:"address"` + Amount []TxAmount `json:"amount"` + OutputIndex float32 `json:"output_index"` + TxHash string `json:"tx_hash"` + DataHash string `json:"data_hash"` + Collateral bool `json:"collateral"` + InlineDatum string `json:"inline_datum"` + ReferenceScriptHash string `json:"reference_script_hash"` + Reference bool `json:"reference"` +} + +type TransactionOutput struct { + Address string `json:"address"` + Amount []TxAmount `json:"amount"` + OutputIndex int `json:"output_index"` + DataHash string `json:"data_hash"` + InlineDatum string `json:"inline_datum"` + ReferenceScriptHash string `json:"reference_script_hash"` +} type TransactionUTXOs struct { // Transaction hash - Hash string `json:"hash"` - Inputs []struct { - Address string `json:"address"` - Amount []TxAmount `json:"amount"` - OutputIndex float32 `json:"output_index"` - TxHash string `json:"tx_hash"` - DataHash string `json:"data_hash"` - Collateral bool `json:"collateral"` - InlineDatum string `json:"inline_datum"` - ReferenceScriptHash string `json:"reference_script_hash"` - Reference bool `json:"reference"` - } `json:"inputs"` - Outputs []struct { - Address string `json:"address"` - Amount []TxAmount `json:"amount"` - OutputIndex int `json:"output_index"` - DataHash string `json:"data_hash"` - InlineDatum string `json:"inline_datum"` - ReferenceScriptHash string `json:"reference_script_hash"` - } `json:"outputs"` + Hash string `json:"hash"` + Inputs []TransactionInput `json:"inputs"` + Outputs []TransactionOutput `json:"outputs"` } type TransactionStakeAddressCert struct { @@ -285,12 +288,12 @@ type Value struct { type TxOutScript interface{} // This is an interface, actual implementation depends on usage -type TxIn struct { +type AdditionalUtxoSetTxIn struct { TxID string `json:"txId"` Index int `json:"index"` } -type TxOut struct { +type AdditionalUtxoSetTxOut struct { Address string `json:"address"` Value Value `json:"value"` DatumHash *string `json:"datumHash,omitempty"` // Pointer to handle null @@ -300,8 +303,8 @@ type TxOut struct { // AdditionalUtxoSet represents a slice of tuples (TxIn, TxOut) type AdditionalUtxoSet []struct { - TxIn TxIn `json:"txIn"` - TxOut TxOut `json:"txOut"` + TxIn AdditionalUtxoSetTxIn `json:"txIn"` + TxOut AdditionalUtxoSetTxOut `json:"txOut"` } type OgmiosResponse struct { diff --git a/webhook.go b/webhook.go new file mode 100644 index 0000000..e358070 --- /dev/null +++ b/webhook.go @@ -0,0 +1,218 @@ +package blockfrost + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type WebhookEventType string + +const ( + WebhookEventTypeBlock WebhookEventType = "block" + WebhookEventTypeDelegation WebhookEventType = "delegation" + WebhookEventTypeEpoch WebhookEventType = "epoch" + WebhookEventTypeTransaction WebhookEventType = "transaction" +) + +type TransactionPayload struct { + Tx Transaction `json:"tx"` + Inputs []TransactionInput `json:"inputs"` + Outputs []TransactionOutput `json:"outputs"` +} + +type StakeDelegationPayload struct { + Tx Transaction `json:"tx"` + Delegations []struct { + TransactionDelegation + Pool Pool `json:"pool"` + } +} + +type EpochPayload struct { + PreviousEpoch Epoch `json:"previous_epoch"` + CurrentEpoch struct { + Epoch int `json:"epoch"` + StartTime int64 `json:"start_time"` + EndTime int64 `json:"end_time"` + } `json:"current_epoch"` +} + +type WebhookEventCommon struct { + ID string `json:"id"` + WebhookID string `json:"webhook_id"` + Created int `json:"created"` + APIVersion int `json:"api_version,omitempty"` // omitempty because test fixtures do not include it +} + +type WebhookEventBlock struct { + WebhookEventCommon + Type string `json:"type"` // "block" + Payload Block `json:"payload"` +} + +type WebhookEventTransaction struct { + WebhookEventCommon + Type string `json:"type"` // "transaction" + Payload []TransactionPayload `json:"payload"` +} + +type WebhookEventEpoch struct { + WebhookEventCommon + Type string `json:"type"` // "epoch" + Payload EpochPayload `json:"payload"` +} + +type WebhookEventDelegation struct { + WebhookEventCommon + Type string `json:"type"` // "delegation" + Payload []StakeDelegationPayload `json:"payload"` +} + +type WebhookEvent interface{} + +const ( + // Signatures older than this will be rejected by ConstructEvent + DefaultTolerance time.Duration = 600 * time.Second + signingVersion string = "v1" +) + +var ( + ErrNotSigned error = errors.New("Missing blockfrost-signature header") + ErrInvalidHeader error = errors.New("Invalid blockfrost-signature header format") + ErrTooOld error = errors.New("Signature's timestamp is not within time tolerance") + ErrNoValidSignature error = errors.New("No valid signature") +) + +func computeSignature(t time.Time, payload []byte, secret string) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(fmt.Sprintf("%d", t.Unix()))) + mac.Write([]byte(".")) + mac.Write(payload) + return mac.Sum(nil) +} + +type signedHeader struct { + timestamp time.Time + signatures [][]byte +} + +func parseSignatureHeader(header string) (*signedHeader, error) { + sh := &signedHeader{} + + if header == "" { + return sh, ErrNotSigned + } + + // Signed header looks like "t=1495999758,v1=ABC,v1=DEF,v0=GHI" + pairs := strings.Split(header, ",") + for _, pair := range pairs { + parts := strings.Split(pair, "=") + if len(parts) != 2 { + return sh, ErrInvalidHeader + } + + switch parts[0] { + case "t": + timestamp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return sh, ErrInvalidHeader + } + sh.timestamp = time.Unix(timestamp, 0) + + case signingVersion: + sig, err := hex.DecodeString(parts[1]) + if err != nil { + continue // Ignore invalid signatures + } + + sh.signatures = append(sh.signatures, sig) + + default: + fmt.Printf("WARNING: Cannot parse part of the signature header, key \"%s\" is not supported by this version of Blockfrost SDK.\n", parts[0]) + continue // Ignore unknown parts of the header + } + } + + if len(sh.signatures) == 0 { + return sh, ErrNoValidSignature + } + + if sh.timestamp == (time.Time{}) { + return sh, ErrInvalidHeader + + } + + return sh, nil +} + +func VerifyWebhookSignature(payload []byte, header string, secret string) (WebhookEvent, error) { + return VerifyWebhookSignatureWithTolerance(payload, header, secret, DefaultTolerance) +} + +func VerifyWebhookSignatureWithTolerance(payload []byte, header string, secret string, tolerance time.Duration) (WebhookEvent, error) { + return verifyWebhookSignature(payload, header, secret, tolerance, true) +} + +func VerifyWebhookSignatureIgnoringTolerance(payload []byte, header string, secret string) (WebhookEvent, error) { + return verifyWebhookSignature(payload, header, secret, 0*time.Second, false) +} + +func verifyWebhookSignature(payload []byte, sigHeader string, secret string, tolerance time.Duration, enforceTolerance bool) (WebhookEvent, error) { + // First unmarshal into a generic map to inspect the type + var genericEvent map[string]interface{} + if err := json.Unmarshal(payload, &genericEvent); err != nil { + return nil, fmt.Errorf("failed to parse webhook body json: %s", err) + } + + // Determine the specific event type + eventType, ok := genericEvent["type"].(string) + if !ok { + return nil, errors.New("event type not found") + } + + var event WebhookEvent + + // Unmarshal into the specific event type based on the eventType + switch eventType { + case string(WebhookEventTypeBlock): + event = new(WebhookEventBlock) + case string(WebhookEventTypeTransaction): + event = new(WebhookEventTransaction) + case string(WebhookEventTypeEpoch): + event = new(WebhookEventEpoch) + case string(WebhookEventTypeDelegation): + event = new(WebhookEventDelegation) + default: + return nil, fmt.Errorf("unknown event type: %s", eventType) + } + + if err := json.Unmarshal(payload, &event); err != nil { + return nil, fmt.Errorf("failed to parse specific webhook event json: %s", err) + } + + header, err := parseSignatureHeader(sigHeader) + if err != nil { + return event, err + } + + expectedSignature := computeSignature(header.timestamp, payload, secret) + expiredTimestamp := time.Since(header.timestamp) > tolerance + if enforceTolerance && expiredTimestamp { + return event, ErrTooOld + } + + for _, sig := range header.signatures { + if hmac.Equal(expectedSignature, sig) { + return event, nil + } + } + + return event, ErrNoValidSignature +} diff --git a/webhook_test.go b/webhook_test.go new file mode 100644 index 0000000..1ce4144 --- /dev/null +++ b/webhook_test.go @@ -0,0 +1,75 @@ +package blockfrost_test + +import ( + "encoding/json" + "testing" + + "github.com/blockfrost/blockfrost-go" +) + +var validPayload string = `{"id":"47668401-c3a4-42d4-bac1-ad46515924a3","webhook_id":"cf68eb9c-635f-415e-a5a8-6233638f28d7","created":1650013856,"type":"block","payload":{"time":1650013853,"height":7126256,"hash":"f49521b67b440e5030adf124aee8f88881b7682ba07acf06c2781405b0f806a4","slot":58447562,"epoch":332,"epoch_slot":386762,"slot_leader":"pool1njjr0zn7uvydjy8067nprgwlyxqnznp9wgllfnag24nycgkda25","size":34617,"tx_count":13,"output":"13403118309871","fees":"4986390","block_vrf":"vrf_vk197w95j9alkwt8l4g7xkccknhn4pqwx65c5saxnn5ej3cpmps72msgpw69d","previous_block":"9e3f5bfc9f0be44cf6e14db9ed5f1efb6b637baff0ea1740bb6711786c724915","next_block":null,"confirmations":0}}` + +func TestVerifyWebhookSignature(t *testing.T) { + event, _ := blockfrost.VerifyWebhookSignatureIgnoringTolerance([]byte(validPayload), + // 2 signatures - first one is invalid, second one is valid + "t=1650013856,v1=abc,t=1650013856,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e", + "59a1eb46-96f4-4f0b-8a03-b4d26e70593a") + + _, ok := event.(*blockfrost.WebhookEventBlock) + if !ok { + jsonData, _ := json.Marshal(event) + t.Fatalf("Invalid webhook type %s", jsonData) + } + + jsonData, err := json.Marshal(event) + if err != nil { + t.Fatalf("Error marshaling to JSON: %s", err) + } + + jsonString := string(jsonData) + + if jsonString != validPayload { + t.Fatalf("\nexpected %v \ngot %v", validPayload, jsonString) + + } +} +func TestVerifyWebhookSignatureOutOfTolerance(t *testing.T) { + _, err := blockfrost.VerifyWebhookSignature([]byte(validPayload), + "t=1650013856,v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e", + "59a1eb46-96f4-4f0b-8a03-b4d26e70593a") + + if err != blockfrost.ErrTooOld { + t.Fatalf("\nsuccess expected %v \ngot %v", blockfrost.ErrTooOld, err) + } + +} +func TestVerifyWebhookSignatureInvalidHeader(t *testing.T) { + _, err := blockfrost.VerifyWebhookSignature([]byte(validPayload), + "v1=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e", + "59a1eb46-96f4-4f0b-8a03-b4d26e70593a") + + if err != blockfrost.ErrInvalidHeader { + t.Fatalf("\nsuccess expected %v \ngot %v", blockfrost.ErrInvalidHeader, err) + } + +} +func TestVerifyWebhookSignatureNoSupportedSchema(t *testing.T) { + _, err := blockfrost.VerifyWebhookSignature([]byte(validPayload), + "v42=f4c3bb2a8b0c8e21fa7d5fdada2ee87c9c6f6b0b159cc22e483146917e195c3e", + "59a1eb46-96f4-4f0b-8a03-b4d26e70593a") + + if err != blockfrost.ErrNoValidSignature { + t.Fatalf("\nsuccess expected %v \ngot %v", blockfrost.ErrNoValidSignature, err) + } + +} +func TestVerifyWebhookSignatureNoHeader(t *testing.T) { + _, err := blockfrost.VerifyWebhookSignature([]byte(validPayload), + "", + "59a1eb46-96f4-4f0b-8a03-b4d26e70593a") + + if err != blockfrost.ErrNotSigned { + t.Fatalf("\nsuccess expected %v \ngot %v", blockfrost.ErrNotSigned, err) + } + +}