πΎ Description
RequestSpendView.Call() in token/services/ttx/multisig/spend.go sends a
SpendRequest to every co-signer of a multisig token and then waits for
all replies on an unbuffered channel with no timeout:
for range counter {
// TODO: put a timeout
a := <-answerChannel // blocks forever if any party goes silent
}
The struct already has a timeout time.Duration field and a public
WithTimeout(time.Duration) setter, but the timeout is never applied to
the actual channel receive. If any remote co-signer crashes, drops the
network connection, or simply does not respond, the loop blocks the
calling goroutine permanently no error is returned, no cleanup runs.
Expected behaviour: if a co-signer does not reply within the configured
timeout, RequestSpendView should return a descriptive error and unblock
the caller.
Actual behaviour: the caller hangs indefinitely when any co-signer is
unresponsive, consuming a goroutine and its associated network session
forever.
π οΈ Steps to reproduce
- Create a multisig token with two or more parties.
- Start a spend flow via RequestSpendView.
- Make one co-signer unavailable (kill the process or drop the network).
- Observe that the initiating node never returns from Call() no error,
no timeout, no progress.
The TODO comment at spend.go:155 explicitly acknowledges the missing
timeout:
// TODO: put a timeout
π Logs and Screenshots
No error is logged. The goroutine simply parks on the channel receive:
// TODO: put a timeout
a := <-answerChannel
A goroutine dump (SIGQUIT / runtime/pprof) will show the goroutine
blocked at spend.go:156 indefinitely.
π Network Environment
Local (NWO / Integration Test)
π¦ Fabric Token SDK Version
main (confirmed at token/services/ttx/multisig/spend.go)
πΉ Go Version
go1.24+
π» Operating System
Linux (Ubuntu/Debian)
β Additional context
Proposed fix wire the existing timeout field into the wait loop:
timer := time.NewTimer(c.timeout)
defer timer.Stop()
for range counter {
select {
case a := <-answerChannel:
if a.err != nil {
return nil, errors.Wrapf(a.err, "failed from [%s]", a.party)
}
if a.response.Err != nil {
return nil, errors.Wrapf(a.response.Err, "got failure from [%s]", a.party)
}
case <-timer.C:
return nil, errors.Errorf("timed out waiting for co-signer response after %s", c.timeout)
}
}
A default timeout (e.g. 30s) should also be set in NewRequestSpendView
so callers that forget WithTimeout() are protected automatically.
Affected file:
token/services/ttx/multisig/spend.go:153-156
πΎ Description
RequestSpendView.Call() in token/services/ttx/multisig/spend.go sends a
SpendRequest to every co-signer of a multisig token and then waits for
all replies on an unbuffered channel with no timeout:
for range counter {
// TODO: put a timeout
a := <-answerChannel // blocks forever if any party goes silent
}
The struct already has a
timeout time.Durationfield and a publicWithTimeout(time.Duration) setter, but the timeout is never applied to
the actual channel receive. If any remote co-signer crashes, drops the
network connection, or simply does not respond, the loop blocks the
calling goroutine permanently no error is returned, no cleanup runs.
Expected behaviour: if a co-signer does not reply within the configured
timeout, RequestSpendView should return a descriptive error and unblock
the caller.
Actual behaviour: the caller hangs indefinitely when any co-signer is
unresponsive, consuming a goroutine and its associated network session
forever.
π οΈ Steps to reproduce
no timeout, no progress.
The TODO comment at spend.go:155 explicitly acknowledges the missing
timeout:
// TODO: put a timeout
π Logs and Screenshots
No error is logged. The goroutine simply parks on the channel receive:
// TODO: put a timeout
a := <-answerChannel
A goroutine dump (SIGQUIT / runtime/pprof) will show the goroutine
blocked at spend.go:156 indefinitely.
π Network Environment
Local (NWO / Integration Test)
π¦ Fabric Token SDK Version
main (confirmed at token/services/ttx/multisig/spend.go)
πΉ Go Version
go1.24+
π» Operating System
Linux (Ubuntu/Debian)
β Additional context
Proposed fix wire the existing timeout field into the wait loop:
timer := time.NewTimer(c.timeout)
defer timer.Stop()
for range counter {
select {
case a := <-answerChannel:
if a.err != nil {
return nil, errors.Wrapf(a.err, "failed from [%s]", a.party)
}
if a.response.Err != nil {
return nil, errors.Wrapf(a.response.Err, "got failure from [%s]", a.party)
}
case <-timer.C:
return nil, errors.Errorf("timed out waiting for co-signer response after %s", c.timeout)
}
}
A default timeout (e.g. 30s) should also be set in NewRequestSpendView
so callers that forget WithTimeout() are protected automatically.
Affected file:
token/services/ttx/multisig/spend.go:153-156