diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5b92a4..1db12699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format ## Table of Contents +- [1.2.12 - 2025-11-12](#1212---2025-11-12) - [1.2.11 - 2025-10-27](#1211---2025-10-27) - [1.2.10 - 2025-09-16](#1210---2025-09-16) - [1.2.9 - 2025-09-07](#129---2025-09-07) @@ -46,6 +47,19 @@ All notable changes to this project will be documented in this file. The format - [1.1.0 - 2024-08-19](#110---2024-08-19) - [1.0.0 - 2024-06-06](#100---2024-06-06) +## [1.2.12] - 2025-11-12 + +### Added +- `ArcBroadcast` method in Arc broadcaster for direct access to ARC response +- Missing ARC status constants: `MINED`, `CONFIRMED`, `DOUBLE_SPEND_ATTEMPTED`, `SEEN_IN_ORPHAN_MEMPOOL` +- Test coverage for fee calculation with `TestCalculateFee` + +### Changed +- Arc broadcaster refactored with `ArcBroadcast` abstraction for better error handling + +### Fixed +- Fee calculation formula to multiply in float space before casting to uint64, ensuring accurate fees for all satoshi rates + ## [1.2.11] - 2025-10-27 ### Added diff --git a/transaction/broadcaster/arc.go b/transaction/broadcaster/arc.go index 6315a124..53eece88 100644 --- a/transaction/broadcaster/arc.go +++ b/transaction/broadcaster/arc.go @@ -17,15 +17,19 @@ import ( type ArcStatus string const ( - REJECTED ArcStatus = "REJECTED" - QUEUED ArcStatus = "QUEUED" - RECEIVED ArcStatus = "RECEIVED" - STORED ArcStatus = "STORED" - ANNOUNCED_TO_NETWORK ArcStatus = "ANNOUNCED_TO_NETWORK" - REQUESTED_BY_NETWORK ArcStatus = "REQUESTED_BY_NETWORK" - SENT_TO_NETWORK ArcStatus = "SENT_TO_NETWORK" - ACCEPTED_BY_NETWORK ArcStatus = "ACCEPTED_BY_NETWORK" - SEEN_ON_NETWORK ArcStatus = "SEEN_ON_NETWORK" + REJECTED ArcStatus = "REJECTED" + QUEUED ArcStatus = "QUEUED" + RECEIVED ArcStatus = "RECEIVED" + STORED ArcStatus = "STORED" + ANNOUNCED_TO_NETWORK ArcStatus = "ANNOUNCED_TO_NETWORK" + REQUESTED_BY_NETWORK ArcStatus = "REQUESTED_BY_NETWORK" + SENT_TO_NETWORK ArcStatus = "SENT_TO_NETWORK" + ACCEPTED_BY_NETWORK ArcStatus = "ACCEPTED_BY_NETWORK" + SEEN_ON_NETWORK ArcStatus = "SEEN_ON_NETWORK" + MINED ArcStatus = "MINED" + CONFIRMED ArcStatus = "CONFIRMED" + DOUBLE_SPEND_ATTEMPTED ArcStatus = "DOUBLE_SPEND_ATTEMPTED" + SEEN_IN_ORPHAN_MEMPOOL ArcStatus = "SEEN_IN_ORPHAN_MEMPOOL" ) type Arc struct { @@ -64,7 +68,7 @@ func (a *Arc) Broadcast(t *transaction.Transaction) (*transaction.BroadcastSucce return a.BroadcastCtx(context.Background(), t) } -func (a *Arc) BroadcastCtx(ctx context.Context, t *transaction.Transaction) (*transaction.BroadcastSuccess, *transaction.BroadcastFailure) { +func (a *Arc) ArcBroadcast(ctx context.Context, t *transaction.Transaction) (*ArcResponse, error) { var buf *bytes.Buffer for _, input := range t.Inputs { if input.SourceTxOutput() == nil { @@ -74,10 +78,7 @@ func (a *Arc) BroadcastCtx(ctx context.Context, t *transaction.Transaction) (*tr } if buf == nil { if ef, err := t.EF(); err != nil { - return nil, &transaction.BroadcastFailure{ - Code: "500", - Description: err.Error(), - } + return nil, err } else { buf = bytes.NewBuffer(ef) } @@ -90,10 +91,7 @@ func (a *Arc) BroadcastCtx(ctx context.Context, t *transaction.Transaction) (*tr buf, ) if err != nil { - return nil, &transaction.BroadcastFailure{ - Code: "500", - Description: err.Error(), - } + return nil, err } req.Header.Set("Content-Type", "application/octet-stream") @@ -140,18 +138,12 @@ func (a *Arc) BroadcastCtx(ctx context.Context, t *transaction.Transaction) (*tr } resp, err := a.Client.Do(req) if err != nil { - return nil, &transaction.BroadcastFailure{ - Code: "500", - Description: err.Error(), - } + return nil, err } defer resp.Body.Close() msg, err := io.ReadAll(resp.Body) if err != nil { - return nil, &transaction.BroadcastFailure{ - Code: "500", - Description: err.Error(), - } + return nil, err } response := &ArcResponse{} @@ -159,6 +151,15 @@ func (a *Arc) BroadcastCtx(ctx context.Context, t *transaction.Transaction) (*tr log.Println("msg", string(msg)) } err = json.Unmarshal(msg, &response) + if err != nil { + return nil, err + } + + return response, nil +} + +func (a *Arc) BroadcastCtx(ctx context.Context, t *transaction.Transaction) (*transaction.BroadcastSuccess, *transaction.BroadcastFailure) { + response, err := a.ArcBroadcast(ctx, t) if err != nil { return nil, &transaction.BroadcastFailure{ Code: "500", diff --git a/transaction/fee_model/sats_per_kb.go b/transaction/fee_model/sats_per_kb.go index 33e0b0fc..709d8fd7 100644 --- a/transaction/fee_model/sats_per_kb.go +++ b/transaction/fee_model/sats_per_kb.go @@ -33,5 +33,9 @@ func (s *SatoshisPerKilobyte) ComputeFee(tx *transaction.Transaction) (uint64, e size += len(*o.LockingScript) } size += 4 - return (uint64(math.Ceil(float64(size) / 1000))) * s.Satoshis, nil + return calculateFee(size, s.Satoshis), nil +} + +func calculateFee(txSizeBytes int, satoshisPerKB uint64) uint64 { + return uint64(math.Ceil(float64(txSizeBytes) / 1000 * float64(satoshisPerKB))) } diff --git a/transaction/fee_model/sats_per_kb_test.go b/transaction/fee_model/sats_per_kb_test.go new file mode 100644 index 00000000..160f8b7c --- /dev/null +++ b/transaction/fee_model/sats_per_kb_test.go @@ -0,0 +1,74 @@ +package feemodel + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCalculateFee(t *testing.T) { + tests := []struct { + name string + txSize int + satoshisPerKB uint64 + expectedFee uint64 + description string + }{ + { + name: "240 bytes at 100 sats/KB", + txSize: 240, + satoshisPerKB: 100, + expectedFee: 24, + description: "240/1000 * 100 = 24 - tests the bug where casting happened before multiplication", + }, + { + name: "240 bytes at 1 sat/KB", + txSize: 240, + satoshisPerKB: 1, + expectedFee: 1, + description: "Edge case that would pass even with buggy implementation", + }, + { + name: "240 bytes at 10 sats/KB", + txSize: 240, + satoshisPerKB: 10, + expectedFee: 3, + description: "240/1000 * 10 = 2.4, ceil = 3", + }, + { + name: "250 bytes at 500 sats/KB", + txSize: 250, + satoshisPerKB: 500, + expectedFee: 125, + description: "250/1000 * 500 = 125", + }, + { + name: "1000 bytes at 100 sats/KB", + txSize: 1000, + satoshisPerKB: 100, + expectedFee: 100, + description: "1000/1000 * 100 = 100", + }, + { + name: "1500 bytes at 100 sats/KB", + txSize: 1500, + satoshisPerKB: 100, + expectedFee: 150, + description: "1500/1000 * 100 = 150", + }, + { + name: "1500 bytes at 500 sats/KB", + txSize: 1500, + satoshisPerKB: 500, + expectedFee: 750, + description: "1500/1000 * 500 = 750", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fee := calculateFee(tt.txSize, tt.satoshisPerKB) + require.Equal(t, tt.expectedFee, fee, tt.description) + }) + } +}