Skip to content

fix: prevent collision between in round and redeem vtxo #537

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ark.trivy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ on:
jobs:
build:
name: Build and Scan
runs-on: ubuntu-20.04
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v2
Expand Down
42 changes: 40 additions & 2 deletions server/internal/core/application/covenantless.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ type covenantlessService struct {
scanner ports.BlockchainScanner
sweeper *sweeper

txRequests *txRequestsQueue
forfeitTxs *forfeitTxsMap
txRequests *txRequestsQueue
forfeitTxs *forfeitTxsMap
redeemTxInputs *outpointMap
roundInputs *outpointMap

eventsCh chan domain.RoundEvent
transactionEventsCh chan TransactionEvent
Expand Down Expand Up @@ -144,6 +146,8 @@ func NewCovenantlessService(
sweeper: newSweeper(walletSvc, repoManager, builder, scheduler, noteUriPrefix),
txRequests: newTxRequestsQueue(),
forfeitTxs: newForfeitTxsMap(builder),
redeemTxInputs: newOutpointMap(),
roundInputs: newOutpointMap(),
eventsCh: make(chan domain.RoundEvent),
transactionEventsCh: make(chan TransactionEvent),
currentRoundLock: sync.Mutex{},
Expand Down Expand Up @@ -252,6 +256,13 @@ func (s *covenantlessService) SubmitRedeemTx(
return "", "", fmt.Errorf("some vtxos not found")
}

if exists, vtxo := s.roundInputs.includesAny(spentVtxoKeys); exists {
return "", "", fmt.Errorf("vtxo %s is already registered for next round", vtxo)
}

s.redeemTxInputs.add(spentVtxoKeys)
defer s.redeemTxInputs.remove(spentVtxoKeys)

vtxoMap := make(map[wire.OutPoint]domain.Vtxo)
for _, vtxo := range spentVtxos {
hash, err := chainhash.NewHashFromStr(vtxo.Txid)
Expand Down Expand Up @@ -650,13 +661,18 @@ func (s *covenantlessService) SpendNotes(ctx context.Context, notes []note.Note)

func (s *covenantlessService) SpendVtxos(ctx context.Context, inputs []ports.Input) (string, error) {
vtxosInputs := make([]domain.Vtxo, 0)
vtxoKeys := make([]domain.VtxoKey, 0)
boardingInputs := make([]ports.BoardingInput, 0)

now := time.Now().Unix()

boardingTxs := make(map[string]wire.MsgTx, 0) // txid -> txhex

for _, input := range inputs {
if s.redeemTxInputs.includes(input.VtxoKey) {
return "", fmt.Errorf("vtxo %s is currently being spent", input.VtxoKey.String())
}

vtxosResult, err := s.repoManager.Vtxos().GetVtxos(ctx, []domain.VtxoKey{input.VtxoKey})
if err != nil || len(vtxosResult) == 0 {
// vtxo not found in db, check if it exists on-chain
Expand Down Expand Up @@ -767,6 +783,7 @@ func (s *covenantlessService) SpendVtxos(ctx context.Context, inputs []ports.Inp
}

vtxosInputs = append(vtxosInputs, vtxo)
vtxoKeys = append(vtxoKeys, vtxo.VtxoKey)
}

request, err := domain.NewTxRequest(vtxosInputs)
Expand All @@ -777,6 +794,9 @@ func (s *covenantlessService) SpendVtxos(ctx context.Context, inputs []ports.Inp
if err := s.txRequests.push(*request, boardingInputs); err != nil {
return "", err
}

s.roundInputs.add(vtxoKeys)

return request.Id, nil
}

Expand Down Expand Up @@ -1364,6 +1384,7 @@ func (s *covenantlessService) startFinalization(roundEndTime time.Time) {

var notes []note.Note
var roundAborted bool
var vtxoKeys []domain.VtxoKey
defer func() {
delete(s.treeSigningSessions, round.Id)
if roundAborted {
Expand All @@ -1376,6 +1397,7 @@ func (s *covenantlessService) startFinalization(roundEndTime time.Time) {
}

if round.IsFailed() {
s.roundInputs.remove(vtxoKeys)
s.startRound()
return
}
Expand Down Expand Up @@ -1404,6 +1426,11 @@ func (s *covenantlessService) startFinalization(roundEndTime time.Time) {
}
requests, boardingInputs, redeeemedNotes, musig2data := s.txRequests.pop(num)
notes = redeeemedNotes
for _, req := range requests {
for _, in := range req.Inputs {
vtxoKeys = append(vtxoKeys, in.VtxoKey)
}
}
s.numOfBoardingInputsMtx.Lock()
s.numOfBoardingInputs = len(boardingInputs)
s.numOfBoardingInputsMtx.Unlock()
Expand Down Expand Up @@ -1640,6 +1667,17 @@ func (s *covenantlessService) finalizeRound(notes []note.Note, roundEndTime time
s.currentRoundLock.Lock()
round := s.currentRound
s.currentRoundLock.Unlock()

defer func() {
vtxoKeys := make([]domain.VtxoKey, 0)
for _, req := range round.TxRequests {
for _, in := range req.Inputs {
vtxoKeys = append(vtxoKeys, in.VtxoKey)
}
}
s.roundInputs.remove(vtxoKeys)
}()

if round.IsFailed() {
return
}
Expand Down
48 changes: 48 additions & 0 deletions server/internal/core/application/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,54 @@ func (m *forfeitTxsMap) allSigned() bool {
return true
}

type outpointMap struct {
lock *sync.RWMutex
outpoints map[string]struct{}
}

func newOutpointMap() *outpointMap {
return &outpointMap{
lock: &sync.RWMutex{},
outpoints: make(map[string]struct{}),
}
}

func (r *outpointMap) add(outpoints []domain.VtxoKey) {
r.lock.Lock()
defer r.lock.Unlock()
for _, out := range outpoints {
r.outpoints[out.String()] = struct{}{}
}
}

func (r *outpointMap) remove(outpoints []domain.VtxoKey) {
r.lock.Lock()
defer r.lock.Unlock()
for _, out := range outpoints {
delete(r.outpoints, out.String())
}
}

func (r *outpointMap) includes(outpoint domain.VtxoKey) bool {
r.lock.RLock()
defer r.lock.RUnlock()
_, exists := r.outpoints[outpoint.String()]
return exists
}

func (r *outpointMap) includesAny(outpoints []domain.VtxoKey) (bool, string) {
r.lock.RLock()
defer r.lock.RUnlock()

for _, out := range outpoints {
if _, exists := r.outpoints[out.String()]; exists {
return true, out.String()
}
}

return false, ""
}

// onchainOutputs iterates over all the nodes' outputs in the vtxo tree and checks their onchain state
// returns the sweepable outputs as ports.SweepInput mapped by their expiration time
func findSweepableOutputs(
Expand Down
76 changes: 76 additions & 0 deletions server/test/e2e/covenantless/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,82 @@ func TestChainOutOfRoundTransactions(t *testing.T) {
require.NotZero(t, balance.Offchain.Total)
}

// TestCollisionBetweenInRoundAndRedeemVtxo tests for a potential collision between VTXOs that could occur
// due to a race condition between simultaneous Settle and SubmitRedeemTx calls. The race condition doesn't
// consistently reproduce, making the test unreliable in automated test suites. Therefore, the test is skipped
// by default and left here as documentation for future debugging and reference.
func TestCollisionBetweenInRoundAndRedeemVtxo(t *testing.T) {
t.Skip()

ctx := context.Background()
alice, grpcAlice := setupArkSDK(t)
defer grpcAlice.Close()

bob, grpcBob := setupArkSDK(t)
defer grpcBob.Close()

_, aliceBoardingAddress, err := alice.Receive(ctx)
require.NoError(t, err)

bobAddr, _, err := bob.Receive(ctx)
require.NoError(t, err)

_, err = utils.RunCommand("nigiri", "faucet", aliceBoardingAddress, "0.00005000")
require.NoError(t, err)

_, err = utils.RunCommand("nigiri", "rpc", "generatetoaddress", "1", "bcrt1qe8eelqalnch946nzhefd5ajhgl2afjw5aegc59")
require.NoError(t, err)
time.Sleep(5 * time.Second)

_, err = alice.Settle(ctx)
require.NoError(t, err)

time.Sleep(1 * time.Second)

//test collision when first Settle is called
type resp struct {
txid string
err error
}

ch := make(chan resp, 2)
wg := &sync.WaitGroup{}
wg.Add(2)

go func() {
defer wg.Done()
txid, err := alice.Settle(ctx)
ch <- resp{txid, err}
}()
// SDK Settle call is bit slower than Redeem so we introduce small delay so we make sure Settle is called before Redeem
// this timeout can vary depending on the environment
time.Sleep(50 * time.Millisecond)
go func() {
defer wg.Done()
txid, err := alice.SendOffChain(ctx, false, []arksdk.Receiver{arksdk.NewBitcoinReceiver(bobAddr, 1000)}, false)
ch <- resp{txid, err}
}()

go func() {
wg.Wait()
close(ch)
}()

finalResp := resp{}
for resp := range ch {
if resp.err != nil {
finalResp.err = resp.err
} else {
finalResp.txid = resp.txid
}
}

t.Log(finalResp.err)
require.NotEmpty(t, finalResp.txid)
require.Error(t, finalResp.err)

}

func TestAliceSendsSeveralTimesToBob(t *testing.T) {
ctx := context.Background()
alice, grpcAlice := setupArkSDK(t)
Expand Down
Loading