diff --git a/internal/core/execute/v2/block/block.go b/internal/core/execute/v2/block/block.go index ab8dee3ff..23fac9b34 100644 --- a/internal/core/execute/v2/block/block.go +++ b/internal/core/execute/v2/block/block.go @@ -21,6 +21,8 @@ type Block struct { State BlockState Batch *database.Batch Executor *Executor + + syntheticCount uint64 } func (b *Block) Params() execute.BlockParams { return b.BlockParams } diff --git a/internal/core/execute/v2/block/exec_process.go b/internal/core/execute/v2/block/exec_process.go index cd12f0d12..9d3ff4447 100644 --- a/internal/core/execute/v2/block/exec_process.go +++ b/internal/core/execute/v2/block/exec_process.go @@ -14,7 +14,9 @@ import ( "gitlab.com/accumulatenetwork/accumulate/internal/database" "gitlab.com/accumulatenetwork/accumulate/pkg/errors" "gitlab.com/accumulatenetwork/accumulate/pkg/types/messaging" + "gitlab.com/accumulatenetwork/accumulate/pkg/url" "gitlab.com/accumulatenetwork/accumulate/protocol" + "golang.org/x/exp/slog" ) // bundle is a bundle of messages to be processed. @@ -194,6 +196,20 @@ func (d *bundle) process() ([]*protocol.TransactionStatus, error) { d.produced = append(d.produced, ctx.produced...) } + // Check for duplicates. This is a serious error if it occurs, but returning + // an error here would effectively stall the network. + pCount := map[[32]byte]int{} + pID := map[[32]byte]*url.TxID{} + for _, m := range d.produced { + pCount[m.Message.Hash()]++ + pID[m.Message.Hash()] = m.Message.ID() + } + for h, c := range pCount { + if c > 1 { + slog.ErrorCtx(d.Context, "Duplicate synthetic messages", "id", pID[h], "count", c) + } + } + // Execute produced messages immediately if and only if the producer and // destination are in the same domain. This implementation is inefficient // but it preserves order and its good enough for now. diff --git a/internal/core/execute/v2/block/msg_common.go b/internal/core/execute/v2/block/msg_common.go index 79e3a3d1d..37e07c4ee 100644 --- a/internal/core/execute/v2/block/msg_common.go +++ b/internal/core/execute/v2/block/msg_common.go @@ -14,6 +14,7 @@ import ( "gitlab.com/accumulatenetwork/accumulate/pkg/types/messaging" "gitlab.com/accumulatenetwork/accumulate/pkg/url" "gitlab.com/accumulatenetwork/accumulate/protocol" + "golang.org/x/exp/slog" ) // MessageContext is the context in which a message is processed. @@ -151,14 +152,37 @@ func (m *MessageContext) queueAdditional(msg messaging.Message) { // didProduce queues a produced synthetic message for dispatch. func (m *MessageContext) didProduce(batch *database.Batch, dest *url.URL, msg messaging.Message) error { - if dest == nil { - panic("nil destination for produced message") - } - m.produced = append(m.produced, &ProducedMessage{ + p := &ProducedMessage{ Producer: m.message.ID(), Destination: dest, Message: msg, - }) + } + + // Add an index to synthetic transactions to differentiate otherwise + // identical messages + m.syntheticCount++ + switch msg := msg.(type) { + case *messaging.TransactionMessage: + if !m.GetActiveGlobals().ExecutorVersion.V2BaikonurEnabled() { + break + } + + // SyntheticForwardTransaction is the only synthetic transaction type + // that does not satisfy this interface, but they are not used in v2 + txn, ok := msg.Transaction.Body.(protocol.SyntheticTransaction) + if !ok { + slog.ErrorCtx(m.Context, "Synthetic transaction is not synthetic", "id", msg.ID()) + break + } + + txn.SetIndex(m.syntheticCount) + p.Message = msg.Copy() // Clear memoized hashes + } + + if dest == nil { + panic("nil destination for produced message") + } + m.produced = append(m.produced, p) err := batch.Message(m.message.Hash()).Produced().Add(msg.ID()) if err != nil { diff --git a/internal/core/execute/v2/block/msg_transaction.go b/internal/core/execute/v2/block/msg_transaction.go index 7ce16dcf5..bce8581d0 100644 --- a/internal/core/execute/v2/block/msg_transaction.go +++ b/internal/core/execute/v2/block/msg_transaction.go @@ -394,9 +394,9 @@ func (x TransactionMessage) executeTransaction(batch *database.Batch, ctx *Trans func (x TransactionMessage) postProcess(batch *database.Batch, ctx *TransactionContext, state *chain.ProcessTransactionState, delivered bool) error { // Calculate refunds - var swos []protocol.SynthTxnWithOrigin + var swos []protocol.SyntheticTransaction for _, newTxn := range state.ProducedTxns { - if swo, ok := newTxn.Body.(protocol.SynthTxnWithOrigin); ok { + if swo, ok := newTxn.Body.(protocol.SyntheticTransaction); ok { swos = append(swos, swo) } } diff --git a/internal/core/execute/v2/block/synthetic.go b/internal/core/execute/v2/block/synthetic.go index 37279cdec..5173c73ad 100644 --- a/internal/core/execute/v2/block/synthetic.go +++ b/internal/core/execute/v2/block/synthetic.go @@ -60,7 +60,7 @@ func (x *Executor) produceSynthetic(batch *database.Batch, produced []*ProducedM // transaction. setSyntheticOrigin sets the refund amount for each synthetic // transaction, spreading the potential refund across all produced synthetic // transactions. -func (x *Executor) setSyntheticOrigin(batch *database.Batch, from *protocol.Transaction, produced []protocol.SynthTxnWithOrigin) error { +func (x *Executor) setSyntheticOrigin(batch *database.Batch, from *protocol.Transaction, produced []protocol.SyntheticTransaction) error { for _, swo := range produced { swo.SetCause(from.ID().Hash(), from.ID().Account()) } diff --git a/internal/core/execute/v2/block/transaction.go b/internal/core/execute/v2/block/transaction.go index ff3fe2bfe..b996222c1 100644 --- a/internal/core/execute/v2/block/transaction.go +++ b/internal/core/execute/v2/block/transaction.go @@ -584,7 +584,7 @@ func (x *TransactionContext) recordFailedTransaction(batch *database.Batch, deli } // If this transaction is a synthetic transaction, send a refund - if swo, ok := delivery.Transaction.Body.(protocol.SynthTxnWithOrigin); ok { + if swo, ok := delivery.Transaction.Body.(protocol.SyntheticTransaction); ok { init, refundAmount := swo.GetRefund() if refundAmount > 0 { refund := new(protocol.SyntheticDepositCredits) diff --git a/protocol/synthetic_origin.go b/protocol/synthetic_origin.go index 376b94672..2bc44abed 100644 --- a/protocol/synthetic_origin.go +++ b/protocol/synthetic_origin.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Accumulate Authors +// Copyright 2023 The Accumulate Authors // // Use of this source code is governed by an MIT-style // license that can be found in the LICENSE file or at @@ -8,15 +8,25 @@ package protocol import "gitlab.com/accumulatenetwork/accumulate/pkg/url" +type SyntheticTransaction interface { + SynthTxnWithOrigin + TransactionBody +} + type SynthTxnWithOrigin interface { GetCause() (cause [32]byte, source *url.URL) SetCause(cause [32]byte, source *url.URL) GetRefund() (initiator *url.URL, refund Fee) SetRefund(initiator *url.URL, refund Fee) + SetIndex(index uint64) } var _ SynthTxnWithOrigin = (*SyntheticOrigin)(nil) var _ SynthTxnWithOrigin = (*SyntheticCreateIdentity)(nil) +var _ SynthTxnWithOrigin = (*SyntheticWriteData)(nil) +var _ SynthTxnWithOrigin = (*SyntheticDepositTokens)(nil) +var _ SynthTxnWithOrigin = (*SyntheticDepositCredits)(nil) +var _ SynthTxnWithOrigin = (*SyntheticBurnTokens)(nil) func (so *SyntheticOrigin) Source() *url.URL { if so.Cause == nil { @@ -41,3 +51,7 @@ func (so *SyntheticOrigin) SetRefund(initiator *url.URL, refund Fee) { so.Initiator = initiator so.FeeRefund = uint64(refund) } + +func (so *SyntheticOrigin) SetIndex(index uint64) { + so.Index = index +} diff --git a/protocol/synthetic_transactions.yml b/protocol/synthetic_transactions.yml index d65d7ff80..5c5016100 100644 --- a/protocol/synthetic_transactions.yml +++ b/protocol/synthetic_transactions.yml @@ -16,6 +16,8 @@ SyntheticOrigin: - name: FeeRefund description: is portion of the cause's fee that will be refunded if this transaction fails type: uint + - name: Index + type: uint SyntheticCreateIdentity: union: { type: transaction } @@ -52,7 +54,7 @@ SyntheticDepositTokens: - name: IsRefund type: bool - + SyntheticDepositCredits: union: { type: transaction } embeddings: diff --git a/protocol/transaction.go b/protocol/transaction.go index 9efccfdbe..56ea211f1 100644 --- a/protocol/transaction.go +++ b/protocol/transaction.go @@ -182,10 +182,6 @@ func (t *Transaction) GetAdditionalAuthorities() []*url.URL { return nil } -type SyntheticTransaction interface { - TransactionBody -} - func (tx *SendTokens) AddRecipient(to *url.URL, amount *big.Int) { recipient := new(TokenRecipient) recipient.Url = to diff --git a/protocol/types_gen.go b/protocol/types_gen.go index 13e32579a..b6efb053c 100644 --- a/protocol/types_gen.go +++ b/protocol/types_gen.go @@ -823,6 +823,7 @@ type SyntheticOrigin struct { Initiator *url.URL `json:"initiator,omitempty" form:"initiator" query:"initiator" validate:"required"` // FeeRefund is portion of the cause's fee that will be refunded if this transaction fails. FeeRefund uint64 `json:"feeRefund,omitempty" form:"feeRefund" query:"feeRefund" validate:"required"` + Index uint64 `json:"index,omitempty" form:"index" query:"index" validate:"required"` extraData []byte } @@ -2947,6 +2948,7 @@ func (v *SyntheticOrigin) Copy() *SyntheticOrigin { u.Initiator = v.Initiator } u.FeeRefund = v.FeeRefund + u.Index = v.Index if len(v.extraData) > 0 { u.extraData = make([]byte, len(v.extraData)) copy(u.extraData, v.extraData) @@ -5215,6 +5217,9 @@ func (v *SyntheticOrigin) Equal(u *SyntheticOrigin) bool { if !(v.FeeRefund == u.FeeRefund) { return false } + if !(v.Index == u.Index) { + return false + } return true } @@ -11166,6 +11171,7 @@ var fieldNames_SyntheticOrigin = []string{ 1: "Cause", 3: "Initiator", 4: "FeeRefund", + 5: "Index", } func (v *SyntheticOrigin) MarshalBinary() ([]byte, error) { @@ -11185,6 +11191,9 @@ func (v *SyntheticOrigin) MarshalBinary() ([]byte, error) { if !(v.FeeRefund == 0) { writer.WriteUint(4, v.FeeRefund) } + if !(v.Index == 0) { + writer.WriteUint(5, v.Index) + } _, _, err := writer.Reset(fieldNames_SyntheticOrigin) if err != nil { @@ -11212,6 +11221,11 @@ func (v *SyntheticOrigin) IsValid() error { } else if v.FeeRefund == 0 { errs = append(errs, "field FeeRefund is not set") } + if len(v.fieldsSet) > 4 && !v.fieldsSet[4] { + errs = append(errs, "field Index is missing") + } else if v.Index == 0 { + errs = append(errs, "field Index is not set") + } switch len(errs) { case 0: @@ -16202,6 +16216,9 @@ func (v *SyntheticOrigin) UnmarshalBinaryFrom(rd io.Reader) error { if x, ok := reader.ReadUint(4); ok { v.FeeRefund = x } + if x, ok := reader.ReadUint(5); ok { + v.Index = x + } seen, err := reader.Reset(fieldNames_SyntheticOrigin) if err != nil { @@ -18675,6 +18692,7 @@ func (v *SyntheticBurnTokens) MarshalJSON() ([]byte, error) { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Amount *string `json:"amount,omitempty"` IsRefund bool `json:"isRefund,omitempty"` }{} @@ -18695,6 +18713,10 @@ func (v *SyntheticBurnTokens) MarshalJSON() ([]byte, error) { u.FeeRefund = v.SyntheticOrigin.FeeRefund } + if !(v.SyntheticOrigin.Index == 0) { + + u.Index = v.SyntheticOrigin.Index + } if !((v.Amount).Cmp(new(big.Int)) == 0) { u.Amount = encoding.BigintToJSON(&v.Amount) } @@ -18711,6 +18733,7 @@ func (v *SyntheticCreateIdentity) MarshalJSON() ([]byte, error) { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Accounts *encoding.JsonUnmarshalListWith[Account] `json:"accounts,omitempty"` }{} u.Type = v.Type() @@ -18730,6 +18753,10 @@ func (v *SyntheticCreateIdentity) MarshalJSON() ([]byte, error) { u.FeeRefund = v.SyntheticOrigin.FeeRefund } + if !(v.SyntheticOrigin.Index == 0) { + + u.Index = v.SyntheticOrigin.Index + } if !(len(v.Accounts) == 0) { u.Accounts = &encoding.JsonUnmarshalListWith[Account]{Value: v.Accounts, Func: UnmarshalAccountJSON} } @@ -18743,6 +18770,7 @@ func (v *SyntheticDepositCredits) MarshalJSON() ([]byte, error) { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Amount uint64 `json:"amount,omitempty"` AcmeRefundAmount *string `json:"acmeRefundAmount,omitempty"` IsRefund bool `json:"isRefund,omitempty"` @@ -18764,6 +18792,10 @@ func (v *SyntheticDepositCredits) MarshalJSON() ([]byte, error) { u.FeeRefund = v.SyntheticOrigin.FeeRefund } + if !(v.SyntheticOrigin.Index == 0) { + + u.Index = v.SyntheticOrigin.Index + } if !(v.Amount == 0) { u.Amount = v.Amount } @@ -18783,6 +18815,7 @@ func (v *SyntheticDepositTokens) MarshalJSON() ([]byte, error) { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Token *url.URL `json:"token,omitempty"` Amount *string `json:"amount,omitempty"` IsIssuer bool `json:"isIssuer,omitempty"` @@ -18805,6 +18838,10 @@ func (v *SyntheticDepositTokens) MarshalJSON() ([]byte, error) { u.FeeRefund = v.SyntheticOrigin.FeeRefund } + if !(v.SyntheticOrigin.Index == 0) { + + u.Index = v.SyntheticOrigin.Index + } if !(v.Token == nil) { u.Token = v.Token } @@ -18859,6 +18896,7 @@ func (v *SyntheticWriteData) MarshalJSON() ([]byte, error) { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Entry *encoding.JsonUnmarshalWith[DataEntry] `json:"entry,omitempty"` }{} u.Type = v.Type() @@ -18878,6 +18916,10 @@ func (v *SyntheticWriteData) MarshalJSON() ([]byte, error) { u.FeeRefund = v.SyntheticOrigin.FeeRefund } + if !(v.SyntheticOrigin.Index == 0) { + + u.Index = v.SyntheticOrigin.Index + } if !(EqualDataEntry(v.Entry, nil)) { u.Entry = &encoding.JsonUnmarshalWith[DataEntry]{Value: v.Entry, Func: UnmarshalDataEntryJSON} } @@ -21178,6 +21220,7 @@ func (v *SyntheticBurnTokens) UnmarshalJSON(data []byte) error { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Amount *string `json:"amount,omitempty"` IsRefund bool `json:"isRefund,omitempty"` }{} @@ -21186,6 +21229,7 @@ func (v *SyntheticBurnTokens) UnmarshalJSON(data []byte) error { u.Source = v.SyntheticOrigin.Source() u.Initiator = v.SyntheticOrigin.Initiator u.FeeRefund = v.SyntheticOrigin.FeeRefund + u.Index = v.SyntheticOrigin.Index u.Amount = encoding.BigintToJSON(&v.Amount) u.IsRefund = v.IsRefund if err := json.Unmarshal(data, &u); err != nil { @@ -21197,6 +21241,7 @@ func (v *SyntheticBurnTokens) UnmarshalJSON(data []byte) error { v.SyntheticOrigin.Cause = u.Cause v.SyntheticOrigin.Initiator = u.Initiator v.SyntheticOrigin.FeeRefund = u.FeeRefund + v.SyntheticOrigin.Index = u.Index if x, err := encoding.BigintFromJSON(u.Amount); err != nil { return fmt.Errorf("error decoding Amount: %w", err) } else { @@ -21213,6 +21258,7 @@ func (v *SyntheticCreateIdentity) UnmarshalJSON(data []byte) error { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Accounts *encoding.JsonUnmarshalListWith[Account] `json:"accounts,omitempty"` }{} u.Type = v.Type() @@ -21220,6 +21266,7 @@ func (v *SyntheticCreateIdentity) UnmarshalJSON(data []byte) error { u.Source = v.SyntheticOrigin.Source() u.Initiator = v.SyntheticOrigin.Initiator u.FeeRefund = v.SyntheticOrigin.FeeRefund + u.Index = v.SyntheticOrigin.Index u.Accounts = &encoding.JsonUnmarshalListWith[Account]{Value: v.Accounts, Func: UnmarshalAccountJSON} if err := json.Unmarshal(data, &u); err != nil { return err @@ -21230,6 +21277,7 @@ func (v *SyntheticCreateIdentity) UnmarshalJSON(data []byte) error { v.SyntheticOrigin.Cause = u.Cause v.SyntheticOrigin.Initiator = u.Initiator v.SyntheticOrigin.FeeRefund = u.FeeRefund + v.SyntheticOrigin.Index = u.Index if u.Accounts != nil { v.Accounts = make([]Account, len(u.Accounts.Value)) for i, x := range u.Accounts.Value { @@ -21246,6 +21294,7 @@ func (v *SyntheticDepositCredits) UnmarshalJSON(data []byte) error { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Amount uint64 `json:"amount,omitempty"` AcmeRefundAmount *string `json:"acmeRefundAmount,omitempty"` IsRefund bool `json:"isRefund,omitempty"` @@ -21255,6 +21304,7 @@ func (v *SyntheticDepositCredits) UnmarshalJSON(data []byte) error { u.Source = v.SyntheticOrigin.Source() u.Initiator = v.SyntheticOrigin.Initiator u.FeeRefund = v.SyntheticOrigin.FeeRefund + u.Index = v.SyntheticOrigin.Index u.Amount = v.Amount u.AcmeRefundAmount = encoding.BigintToJSON(v.AcmeRefundAmount) u.IsRefund = v.IsRefund @@ -21267,6 +21317,7 @@ func (v *SyntheticDepositCredits) UnmarshalJSON(data []byte) error { v.SyntheticOrigin.Cause = u.Cause v.SyntheticOrigin.Initiator = u.Initiator v.SyntheticOrigin.FeeRefund = u.FeeRefund + v.SyntheticOrigin.Index = u.Index v.Amount = u.Amount if x, err := encoding.BigintFromJSON(u.AcmeRefundAmount); err != nil { return fmt.Errorf("error decoding AcmeRefundAmount: %w", err) @@ -21284,6 +21335,7 @@ func (v *SyntheticDepositTokens) UnmarshalJSON(data []byte) error { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Token *url.URL `json:"token,omitempty"` Amount *string `json:"amount,omitempty"` IsIssuer bool `json:"isIssuer,omitempty"` @@ -21294,6 +21346,7 @@ func (v *SyntheticDepositTokens) UnmarshalJSON(data []byte) error { u.Source = v.SyntheticOrigin.Source() u.Initiator = v.SyntheticOrigin.Initiator u.FeeRefund = v.SyntheticOrigin.FeeRefund + u.Index = v.SyntheticOrigin.Index u.Token = v.Token u.Amount = encoding.BigintToJSON(&v.Amount) u.IsIssuer = v.IsIssuer @@ -21307,6 +21360,7 @@ func (v *SyntheticDepositTokens) UnmarshalJSON(data []byte) error { v.SyntheticOrigin.Cause = u.Cause v.SyntheticOrigin.Initiator = u.Initiator v.SyntheticOrigin.FeeRefund = u.FeeRefund + v.SyntheticOrigin.Index = u.Index v.Token = u.Token if x, err := encoding.BigintFromJSON(u.Amount); err != nil { return fmt.Errorf("error decoding Amount: %w", err) @@ -21365,6 +21419,7 @@ func (v *SyntheticWriteData) UnmarshalJSON(data []byte) error { Source *url.URL `json:"source,omitempty"` Initiator *url.URL `json:"initiator,omitempty"` FeeRefund uint64 `json:"feeRefund,omitempty"` + Index uint64 `json:"index,omitempty"` Entry *encoding.JsonUnmarshalWith[DataEntry] `json:"entry,omitempty"` }{} u.Type = v.Type() @@ -21372,6 +21427,7 @@ func (v *SyntheticWriteData) UnmarshalJSON(data []byte) error { u.Source = v.SyntheticOrigin.Source() u.Initiator = v.SyntheticOrigin.Initiator u.FeeRefund = v.SyntheticOrigin.FeeRefund + u.Index = v.SyntheticOrigin.Index u.Entry = &encoding.JsonUnmarshalWith[DataEntry]{Value: v.Entry, Func: UnmarshalDataEntryJSON} if err := json.Unmarshal(data, &u); err != nil { return err @@ -21382,6 +21438,7 @@ func (v *SyntheticWriteData) UnmarshalJSON(data []byte) error { v.SyntheticOrigin.Cause = u.Cause v.SyntheticOrigin.Initiator = u.Initiator v.SyntheticOrigin.FeeRefund = u.FeeRefund + v.SyntheticOrigin.Index = u.Index if u.Entry != nil { v.Entry = u.Entry.Value } diff --git a/test/e2e/txn_send_tokens_test.go b/test/e2e/txn_send_tokens_test.go new file mode 100644 index 000000000..9cf7edcaa --- /dev/null +++ b/test/e2e/txn_send_tokens_test.go @@ -0,0 +1,82 @@ +// Copyright 2023 The Accumulate Authors +// +// Use of this source code is governed by an MIT-style +// license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +package e2e + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + "gitlab.com/accumulatenetwork/accumulate/pkg/build" + "gitlab.com/accumulatenetwork/accumulate/pkg/url" + . "gitlab.com/accumulatenetwork/accumulate/protocol" + . "gitlab.com/accumulatenetwork/accumulate/test/harness" + . "gitlab.com/accumulatenetwork/accumulate/test/helpers" + "gitlab.com/accumulatenetwork/accumulate/test/simulator" + acctesting "gitlab.com/accumulatenetwork/accumulate/test/testing" +) + +// TestSendTokens_DuplicateRecipients tests sending two outputs to the same +// recipient. +func TestSendTokens_DuplicateRecipients(t *testing.T) { + alice := url.MustParse("alice") + bob := url.MustParse("bob") + aliceKey := acctesting.GenerateKey(alice) + bobKey := acctesting.GenerateKey(bob) + + cases := map[string]struct { + Recipient *url.URL + Amounts [2]int + }{ + "Same domain, same amount": {Recipient: alice, Amounts: [2]int{1, 1}}, + "Same domain, different amount": {Recipient: alice, Amounts: [2]int{1, 2}}, + "Different domain, same amount": {Recipient: bob, Amounts: [2]int{1, 1}}, + "Different domain, different amount": {Recipient: bob, Amounts: [2]int{1, 2}}, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + // Initialize + sim := NewSim(t, + simulator.SimpleNetwork(t.Name(), 1, 1), + simulator.Genesis(GenesisTime), + ) + + total := c.Amounts[0] + c.Amounts[1] + + MakeIdentity(t, sim.DatabaseFor(alice), alice, aliceKey[32:]) + CreditCredits(t, sim.DatabaseFor(alice), alice.JoinPath("book", "1"), 1e9) + MakeAccount(t, sim.DatabaseFor(alice), &TokenAccount{Url: alice.JoinPath("tokens"), TokenUrl: AcmeUrl()}) + CreditTokens(t, sim.DatabaseFor(alice), alice.JoinPath("tokens"), big.NewInt(int64(total))) + MakeIdentity(t, sim.DatabaseFor(bob), bob, bobKey[32:]) + MakeAccount(t, sim.DatabaseFor(bob), &TokenAccount{Url: bob.JoinPath("tokens"), TokenUrl: AcmeUrl()}) + + // Execute + st := sim.BuildAndSubmitTxnSuccessfully( + build.Transaction().For(alice, "tokens"). + SendTokens(c.Amounts[0], 0).To(c.Recipient, "tokens"). + And(c.Amounts[1], 0).To(c.Recipient, "tokens"). + SignWith(alice, "book", "1").Version(1).Timestamp(1).PrivateKey(aliceKey)) + + sim.StepUntil( + Txn(st.TxID).Succeeds(), + Txn(st.TxID).Produced().Succeeds()) + + // Give some time for all the synthetic messages to settle + sim.StepN(10) + + // Verify the ledger + part := PartitionUrl("BVN0") + ledger := GetAccount[*SyntheticLedger](t, sim.Database("BVN0"), part.JoinPath(Synthetic)).Partition(part) + require.Equal(t, ledger.Delivered, ledger.Produced) + + // Verify the amount + account := GetAccount[*TokenAccount](t, sim.DatabaseFor(c.Recipient), c.Recipient.JoinPath("tokens")) + require.Equal(t, total, int(account.Balance.Int64())) + }) + } +}