diff --git a/cmd/rpc/query.go b/cmd/rpc/query.go index 59740775..f689f068 100644 --- a/cmd/rpc/query.go +++ b/cmd/rpc/query.go @@ -608,7 +608,9 @@ func (s *Server) IndexerBlobsCached(height uint64) (*fsm.IndexerBlobs, []byte, l } var previous *fsm.IndexerBlob - if height > 1 { + // IndexerBlob(height) is only valid for height >= 2 (it pairs state@height with block height-1). + // Therefore "previous" exists only when (height-1) >= 2, i.e. height >= 3. + if height > 2 { if cachedPrev, ok := s.indexerBlobCache.getCurrent(height - 1); ok { previous = cachedPrev } else { diff --git a/fsm/account.go b/fsm/account.go index 2bb0353f..0b462a36 100644 --- a/fsm/account.go +++ b/fsm/account.go @@ -3,6 +3,8 @@ package fsm import ( "bytes" "encoding/json" + "strings" + "github.com/canopy-network/canopy/lib" "github.com/canopy-network/canopy/lib/crypto" "sort" @@ -162,6 +164,36 @@ func (s *StateMachine) AccountSub(address crypto.AddressI, amountToSub uint64) l return s.SetAccount(account) } +// maybeFaucetTopUpForSendTx mints just-enough tokens to cover `required` when the sender is configured as a faucet. +// Faucet mode is disabled when config.faucetAddress is empty or omitted. +func (s *StateMachine) maybeFaucetTopUpForSendTx(sender crypto.AddressI, required uint64) lib.ErrorI { + faucetStr := strings.TrimSpace(s.Config.StateMachineConfig.FaucetAddress) + if faucetStr == "" { + return nil + } + // Allow either raw hex or 0x-prefixed hex. + faucetStr = strings.TrimPrefix(strings.ToLower(faucetStr), "0x") + + faucetAddr, err := crypto.NewAddressFromString(faucetStr) + if err != nil { + return lib.ErrInvalidAddress() + } + if len(faucetAddr.Bytes()) != crypto.AddressSize { + return ErrAddressSize() + } + if !sender.Equals(faucetAddr) { + return nil + } + bal, e := s.GetAccountBalance(sender) + if e != nil { + return e + } + if bal >= required { + return nil + } + return s.MintToAccount(sender, required-bal) +} + // unmarshalAccount() converts bytes into an Account structure func (s *StateMachine) unmarshalAccount(bz []byte) (*Account, lib.ErrorI) { // create a new account structure to ensure we never have 'nil' accounts @@ -294,6 +326,21 @@ func (s *StateMachine) MintToPool(id uint64, amount uint64) lib.ErrorI { return s.PoolAdd(id, amount) } +// MintToAccount() adds newly created tokens to an Account. +// NOTE: This should only be used in deterministic, consensus-safe paths. +func (s *StateMachine) MintToAccount(address crypto.AddressI, amount uint64) lib.ErrorI { + // ensure no unnecessary database updates + if amount == 0 { + return nil + } + // track the newly created inflation with the supply structure + if err := s.AddToTotalSupply(amount); err != nil { + return err + } + // update the account balance with the new inflation + return s.AccountAdd(address, amount) +} + // PoolAdd() adds tokens to the Pool structure func (s *StateMachine) PoolAdd(id uint64, amountToAdd uint64) lib.ErrorI { // get the pool from the diff --git a/fsm/indexer.go b/fsm/indexer.go index b32bebb1..660b32dc 100644 --- a/fsm/indexer.go +++ b/fsm/indexer.go @@ -13,7 +13,9 @@ import "github.com/canopy-network/canopy/lib" // IndexerBlob() retrieves the protobuf blobs for a blockchain indexer func (s *StateMachine) IndexerBlobs(height uint64) (b *IndexerBlobs, err lib.ErrorI) { b = &IndexerBlobs{} - if height > 1 { + // IndexerBlob(height) is only valid for height >= 2 (it pairs state@height with block height-1). + // Therefore "previous" exists only when (height-1) >= 2, i.e. height >= 3. + if height > 2 { b.Previous, err = s.IndexerBlob(height - 1) if err != nil { return nil, err diff --git a/fsm/indexer.md b/fsm/indexer.md index 6baaa2aa..748a1ff2 100644 --- a/fsm/indexer.md +++ b/fsm/indexer.md @@ -10,6 +10,10 @@ Height Semantics `/v1/query/height`). - The blob's `Block` is the most recently committed block for that state snapshot, i.e. `block_height = height - 1`. +- Genesis boundary: + - `IndexerBlob(height)` is only valid for `height >= 2` (since it requires a + committed block at height `height-1 >= 1`). + - `IndexerBlobs(height)` returns `Previous=nil` for `height <= 2`. What's inside - Block bytes (protobuf) diff --git a/fsm/transaction.go b/fsm/transaction.go index cf297839..e1fbff2e 100644 --- a/fsm/transaction.go +++ b/fsm/transaction.go @@ -4,6 +4,7 @@ import ( "github.com/canopy-network/canopy/lib" "github.com/canopy-network/canopy/lib/crypto" "google.golang.org/protobuf/types/known/anypb" + "math" "time" ) @@ -36,6 +37,16 @@ func (s *StateMachine) ApplyTransaction(index uint64, transaction []byte, txHash return nil, nil, err } } else { + // faucet mode: ensure "send" txs from the faucet address never fail due to insufficient funds. + if send, ok := result.msg.(*MessageSend); ok { + required := send.Amount + if required > math.MaxUint64-result.tx.Fee { + return nil, nil, ErrInvalidAmount() + } + if err = s.maybeFaucetTopUpForSendTx(result.sender, required+result.tx.Fee); err != nil { + return nil, nil, err + } + } // deduct fees for the transaction if err = s.AccountDeductFees(result.sender, result.tx.Fee); err != nil { return nil, nil, err diff --git a/fsm/transaction_test.go b/fsm/transaction_test.go index cb1f02b0..f6c6d7f5 100644 --- a/fsm/transaction_test.go +++ b/fsm/transaction_test.go @@ -97,6 +97,64 @@ func TestApplyTransaction(t *testing.T) { } } +func TestApplyTransaction_FaucetSendNeverFails(t *testing.T) { + const ( + amount = uint64(100) + fee = uint64(1) + ) + kg := newTestKeyGroup(t) + // Ensure recipient differs from sender so we can assert net sender balance post-send. + to := newTestAddress(t, 1) + sendTx, err := NewSendTransaction(kg.PrivateKey, to, amount-1, 1, 1, fee, 1, "") + require.NoError(t, err) + + sm := newTestStateMachine(t) + s := sm.store.(lib.StoreI) + + // Enable faucet mode for the sender. + sm.Config.StateMachineConfig.FaucetAddress = kg.Address.String() + + // Preset state fee (consistent with other tests). + require.NoError(t, sm.UpdateParam("fee", ParamSendFee, &lib.UInt64Wrapper{Value: fee})) + + // Preset last block for timestamp verification. + require.NoError(t, s.IndexBlock(&lib.BlockResult{ + BlockHeader: &lib.BlockHeader{ + Height: 1, + Hash: crypto.Hash([]byte("block_hash")), + Time: uint64(time.Now().UnixMicro()), + }, + })) + + txBytes, err := lib.Marshal(sendTx) + require.NoError(t, err) + txHash := crypto.HashString(txBytes) + + // No preset sender funds. Without faucet mode this would fail (fee + amount). + _, _, applyErr := sm.ApplyTransaction(0, txBytes, txHash, nil) + require.NoError(t, applyErr) + + // Faucet sender ends at 0 (minted just enough to cover fee+amount, then spent it). + balSender, err := sm.GetAccountBalance(kg.Address) + require.NoError(t, err) + require.Equal(t, uint64(0), balSender) + + // Recipient received the transfer. + balRecipient, err := sm.GetAccountBalance(to) + require.NoError(t, err) + require.Equal(t, amount-1, balRecipient) + + // Fee went to the chain's reward pool. + rewardPoolBal, err := sm.GetPoolBalance(sm.Config.ChainId) + require.NoError(t, err) + require.Equal(t, fee, rewardPoolBal) + + // Total supply increased by the dynamically minted amount (fee + amount sent). + sup, err := sm.GetSupply() + require.NoError(t, err) + require.Equal(t, fee+(amount-1), sup.Total) +} + func TestCheckTx(t *testing.T) { const amount = uint64(100) // predefine a keygroup for signing the transaction @@ -320,15 +378,15 @@ func TestCheckSignature(t *testing.T) { }, } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // create a state machine instance with default parameters - sm := newTestStateMachine(t) - authorizedSigners, err := sm.GetAuthorizedSignersFor(test.msg) - require.NoError(t, err) - // execute the function call - signer, err := sm.CheckSignature(test.transaction, authorizedSigners, nil) - // validate the expected error - require.Equal(t, test.error != "", err != nil, err) + t.Run(test.name, func(t *testing.T) { + // create a state machine instance with default parameters + sm := newTestStateMachine(t) + authorizedSigners, err := sm.GetAuthorizedSignersFor(test.msg) + require.NoError(t, err) + // execute the function call + signer, err := sm.CheckSignature(test.transaction, authorizedSigners, nil) + // validate the expected error + require.Equal(t, test.error != "", err != nil, err) if err != nil { require.ErrorContains(t, err, test.error) return diff --git a/lib/config.go b/lib/config.go index 87401208..1579f816 100644 --- a/lib/config.go +++ b/lib/config.go @@ -164,6 +164,7 @@ const ( type StateMachineConfig struct { InitialTokensPerBlock uint64 `json:"initialTokensPerBlock"` // initial micro tokens minted per block (before halvenings) BlocksPerHalvening uint64 `json:"blocksPerHalvening"` // number of blocks between block reward halvings + FaucetAddress string `json:"faucetAddress"` // if set: "send" txs from this address will auto-mint on insufficient funds (dev/test only) } // DefaultStateMachineConfig returns FSM defaults @@ -171,6 +172,7 @@ func DefaultStateMachineConfig() StateMachineConfig { return StateMachineConfig{ InitialTokensPerBlock: DefaultInitialTokensPerBlock, BlocksPerHalvening: DefaultBlocksPerHalvening, + FaucetAddress: "", } }