diff --git a/go.sum b/go.sum index 73dadc3e22..d51a578e43 100644 --- a/go.sum +++ b/go.sum @@ -657,8 +657,6 @@ github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXn github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/second-state/WasmEdge-go v0.13.3 h1:ZUPMQKJH0FHVGvBiobuEkNOKMfIL20fP0H5aaqG1JtY= -github.com/second-state/WasmEdge-go v0.13.3/go.mod h1:HyBf9hVj1sRAjklsjc1Yvs9b5RcmthPG9z99dY78TKg= github.com/second-state/WasmEdge-go v0.13.4 h1:NHfJC+aayUW93ydAzlcX7Jx1WDRpI24KvY5SAbeTyvY= github.com/second-state/WasmEdge-go v0.13.4/go.mod h1:HyBf9hVj1sRAjklsjc1Yvs9b5RcmthPG9z99dY78TKg= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= diff --git a/packages/chain/mempool/mempool.go b/packages/chain/mempool/mempool.go index 1885e4f7b3..531cad1c58 100644 --- a/packages/chain/mempool/mempool.go +++ b/packages/chain/mempool/mempool.go @@ -649,6 +649,10 @@ func (mpi *mempoolImpl) handleReceiveOnLedgerRequest(request isc.OnLedgerRequest mpi.log.Warnf("dropping request, because it has ReturnAmount, ID=%v", requestID) return } + if request.SenderAccount() == nil { + // do not process requests without the sender feature + return + } // // Check, maybe mempool already has it. if mpi.onLedgerPool.Has(requestRef) || mpi.timePool.Has(requestRef) { diff --git a/packages/chain/mempool/mempool_test.go b/packages/chain/mempool/mempool_test.go index 1400230a24..92109ad891 100644 --- a/packages/chain/mempool/mempool_test.go +++ b/packages/chain/mempool/mempool_test.go @@ -779,7 +779,7 @@ func getRequestsOnLedger(t *testing.T, chainAddress iotago.Address, amount int, } output := transaction.BasicOutputFromPostData( tpkg.RandEd25519Address(), - isc.Hn("dummySenderContract"), + 0, requestParams, ) outputID := tpkg.RandOutputID(uint16(i)) diff --git a/packages/isc/sandbox_interface.go b/packages/isc/sandbox_interface.go index 4a3d2abada..657f0457a0 100644 --- a/packages/isc/sandbox_interface.go +++ b/packages/isc/sandbox_interface.go @@ -116,8 +116,6 @@ type Sandbox interface { EstimateRequiredStorageDeposit(r RequestParameters) uint64 // StateAnchor properties of the anchor output StateAnchor() *StateAnchor - // MintNFT mints an NFT - // MintNFT(metadata []byte) // TODO returns a temporary ID // EVMTracer returns a non-nil tracer if an EVM tx is being traced // (e.g. with the debug_traceTransaction JSONRPC method). @@ -138,6 +136,7 @@ type Privileged interface { CreateNewFoundry(scheme iotago.TokenScheme, metadata []byte) (uint32, uint64) DestroyFoundry(uint32) uint64 ModifyFoundrySupply(serNum uint32, delta *big.Int) int64 + MintNFT(addr iotago.Address, immutableMetadata []byte, issuer iotago.Address) (uint16, *iotago.NFTOutput) GasBurnEnable(enable bool) MustMoveBetweenAccounts(fromAgentID, toAgentID AgentID, assets *Assets) DebitFromAccount(AgentID, *Assets) diff --git a/packages/kv/codec/encodego.go b/packages/kv/codec/encodego.go index b0e3a5c0c9..d7ee5e85ee 100644 --- a/packages/kv/codec/encodego.go +++ b/packages/kv/codec/encodego.go @@ -58,6 +58,8 @@ func Encode(v interface{}) []byte { return EncodeRequestID(*vt) case isc.Hname: return vt.Bytes() + case iotago.NFTID: + return EncodeNFTID(vt) case isc.VMErrorCode: return vt.Bytes() case time.Time: diff --git a/packages/solo/ledgerl1l2.go b/packages/solo/ledgerl1l2.go index e8c994f8e8..33b8cf9b74 100644 --- a/packages/solo/ledgerl1l2.go +++ b/packages/solo/ledgerl1l2.go @@ -358,13 +358,13 @@ func (ch *Chain) MustDepositNFT(nft *isc.NFT, to isc.AgentID, owner *cryptolib.K // Withdraw sends assets from the L2 account to L1 func (ch *Chain) Withdraw(assets *isc.Assets, user *cryptolib.KeyPair) error { - _, err := ch.PostRequestSync( - NewCallParams(accounts.Contract.Name, accounts.FuncWithdraw.Name). - AddAllowance(assets). - AddAllowance(isc.NewAssetsBaseTokens(1*isc.Million)). // for storage deposit - WithGasBudget(math.MaxUint64), - user, - ) + req := NewCallParams(accounts.Contract.Name, accounts.FuncWithdraw.Name). + AddAllowance(assets). + WithGasBudget(math.MaxUint64) + if assets.BaseTokens == 0 { + req.AddAllowance(isc.NewAssetsBaseTokens(1 * isc.Million)) // for storage deposit + } + _, err := ch.PostRequestOffLedger(req, user) return err } diff --git a/packages/solo/mempool.go b/packages/solo/mempool.go index 154fb0034a..f130ace60d 100644 --- a/packages/solo/mempool.go +++ b/packages/solo/mempool.go @@ -8,6 +8,7 @@ package solo import ( "fmt" + "sync" "time" "github.com/iotaledger/wasp/packages/isc" @@ -30,18 +31,27 @@ type mempoolImpl struct { requests map[isc.RequestID]isc.Request info MempoolInfo currentTime func() time.Time + chainID isc.ChainID + mu sync.Mutex } -func newMempool(currentTime func() time.Time) Mempool { +func newMempool(currentTime func() time.Time, chainID isc.ChainID) Mempool { return &mempoolImpl{ requests: map[isc.RequestID]isc.Request{}, info: MempoolInfo{}, currentTime: currentTime, + chainID: chainID, + mu: sync.Mutex{}, } } func (mi *mempoolImpl) ReceiveRequests(reqs ...isc.Request) { + mi.mu.Lock() + defer mi.mu.Unlock() for _, req := range reqs { + if req.SenderAccount() == nil { + continue // ignore requests without a sender + } if _, ok := mi.requests[req.ID()]; !ok { mi.info.TotalPool++ mi.info.InPoolCounter++ @@ -51,6 +61,8 @@ func (mi *mempoolImpl) ReceiveRequests(reqs ...isc.Request) { } func (mi *mempoolImpl) RequestBatchProposal() []isc.Request { + mi.mu.Lock() + defer mi.mu.Unlock() now := mi.currentTime() batch := []isc.Request{} for rid, request := range mi.requests { @@ -78,6 +90,8 @@ func (mi *mempoolImpl) RequestBatchProposal() []isc.Request { } func (mi *mempoolImpl) RemoveRequest(rID isc.RequestID) { + mi.mu.Lock() + defer mi.mu.Unlock() if _, ok := mi.requests[rID]; ok { mi.info.OutPoolCounter++ mi.info.TotalPool-- diff --git a/packages/solo/req.go b/packages/solo/req.go index 429fb9dab8..2a3c2d0206 100644 --- a/packages/solo/req.go +++ b/packages/solo/req.go @@ -454,16 +454,16 @@ func (ch *Chain) CallView(scName, funName string, params ...interface{}) (dict.D func (ch *Chain) CallViewAtState(chainState state.State, scName, funName string, params ...interface{}) (dict.Dict, error) { ch.Log().Debugf("callView: %s::%s", scName, funName) - return ch.CallViewByHnameAtState(chainState, isc.Hn(scName), isc.Hn(funName), params...) + return ch.callViewByHnameAtState(chainState, isc.Hn(scName), isc.Hn(funName), params...) } func (ch *Chain) CallViewByHname(hContract, hFunction isc.Hname, params ...interface{}) (dict.Dict, error) { latestState, err := ch.store.LatestState() require.NoError(ch.Env.T, err) - return ch.CallViewByHnameAtState(latestState, hContract, hFunction, params...) + return ch.callViewByHnameAtState(latestState, hContract, hFunction, params...) } -func (ch *Chain) CallViewByHnameAtState(chainState state.State, hContract, hFunction isc.Hname, params ...interface{}) (dict.Dict, error) { +func (ch *Chain) callViewByHnameAtState(chainState state.State, hContract, hFunction isc.Hname, params ...interface{}) (dict.Dict, error) { ch.Log().Debugf("callView: %s::%s", hContract.String(), hFunction.String()) p := parseParams(params) diff --git a/packages/solo/run.go b/packages/solo/run.go index 0d76544738..b4300bb02f 100644 --- a/packages/solo/run.go +++ b/packages/solo/run.go @@ -42,9 +42,6 @@ func (ch *Chain) RunOffLedgerRequests(reqs []isc.Request) []*vm.RequestResult { func (ch *Chain) RunRequestsSync(reqs []isc.Request, trace string) (results []*vm.RequestResult) { ch.runVMMutex.Lock() defer ch.runVMMutex.Unlock() - - ch.mempool.ReceiveRequests(reqs...) - return ch.runRequestsNolock(reqs, trace) } @@ -128,6 +125,8 @@ func (ch *Chain) runRequestsNolock(reqs []isc.Request, trace string) (results [] l1C := ch.GetL1Commitment() require.Equal(ch.Env.T, rootC, l1C.TrieRoot()) + ch.Env.EnqueueRequests(tx) + return res.RequestResults } @@ -155,8 +154,6 @@ func (ch *Chain) settleStateTransition(stateTx *iotago.Transaction, stateDraft s } ch.Log().Infof("state transition --> #%d. Requests in the block: %d. Outputs: %d", stateDraft.BlockIndex(), len(blockReceipts), len(stateTx.Essence.Outputs)) - - go ch.Env.EnqueueRequests(stateTx) } func (ch *Chain) logRequestLastBlock() { diff --git a/packages/solo/snapshot.go b/packages/solo/snapshot.go index 99374e60f2..cfb88237d5 100644 --- a/packages/solo/snapshot.go +++ b/packages/solo/snapshot.go @@ -3,6 +3,7 @@ package solo import ( "encoding/json" "os" + "sync" "github.com/stretchr/testify/require" @@ -29,8 +30,8 @@ type soloChainSnapshot struct { // SaveSnapshot generates a snapshot of the Solo environment func (env *Solo) SaveSnapshot(fname string) { - env.glbMutex.Lock() - defer env.glbMutex.Unlock() + env.chainsMutex.Lock() + defer env.chainsMutex.Unlock() snapshot := soloSnapshot{ UtxoDB: env.utxoDB.State(), @@ -62,8 +63,8 @@ func (env *Solo) SaveSnapshot(fname string) { // LoadSnapshot restores the Solo environment from the given snapshot func (env *Solo) LoadSnapshot(fname string) { - env.glbMutex.Lock() - defer env.glbMutex.Unlock() + env.chainsMutex.Lock() + defer env.chainsMutex.Unlock() b, err := os.ReadFile(fname) require.NoError(env.T, err) @@ -99,6 +100,7 @@ func (env *Solo) LoadSnapshot(fname string) { OriginatorPrivateKey: okp, ValidatorFeeTarget: val, db: db, + writeMutex: &sync.Mutex{}, } env.addChain(chainData) } diff --git a/packages/solo/solo.go b/packages/solo/solo.go index 0755198b0d..56d5b764f3 100644 --- a/packages/solo/solo.go +++ b/packages/solo/solo.go @@ -41,6 +41,7 @@ import ( "github.com/iotaledger/wasp/packages/vm/core/coreprocessors" "github.com/iotaledger/wasp/packages/vm/core/governance" "github.com/iotaledger/wasp/packages/vm/core/migrations" + "github.com/iotaledger/wasp/packages/vm/core/migrations/allmigrations" "github.com/iotaledger/wasp/packages/vm/processors" _ "github.com/iotaledger/wasp/packages/vm/sandbox" "github.com/iotaledger/wasp/packages/vm/vmtypes" @@ -59,7 +60,7 @@ type Solo struct { logger *logger.Logger chainStateDatabaseManager *database.ChainStateDatabaseManager utxoDB *utxodb.UtxoDB - glbMutex sync.RWMutex + chainsMutex sync.RWMutex ledgerMutex sync.RWMutex chains map[isc.ChainID]*Chain processorConfig *processors.Config @@ -212,8 +213,8 @@ func (env *Solo) Publisher() *publisher.Publisher { } func (env *Solo) GetChainByName(name string) *Chain { - env.glbMutex.Lock() - defer env.glbMutex.Unlock() + env.chainsMutex.Lock() + defer env.chainsMutex.Unlock() for _, ch := range env.chains { if ch.Name == name { return ch @@ -343,8 +344,8 @@ func (env *Solo) NewChainExt( ) (*Chain, *iotago.Transaction) { chData, originTx := env.deployChain(chainOriginator, initBaseTokens, name, originParams...) - env.glbMutex.Lock() - defer env.glbMutex.Unlock() + env.chainsMutex.Lock() + defer env.chainsMutex.Unlock() ch := env.addChain(chData) ch.log.Infof("chain '%s' deployed. Chain ID: %s", ch.Name, ch.ChainID.String()) @@ -362,7 +363,8 @@ func (env *Solo) addChain(chData chainData) *Chain { proc: processors.MustNew(env.processorConfig), log: env.logger.Named(chData.Name), metrics: metrics.NewChainMetricsProvider().GetChainMetrics(chData.ChainID), - mempool: newMempool(env.utxoDB.GlobalTime), + mempool: newMempool(env.utxoDB.GlobalTime, chData.ChainID), + migrationScheme: allmigrations.DefaultScheme, } env.chains[chData.ChainID] = ch go ch.batchLoop() @@ -377,8 +379,8 @@ func (env *Solo) AddToLedger(tx *iotago.Transaction) error { // RequestsForChain parses the transaction and returns all requests contained in it which have chainID as the target func (env *Solo) RequestsForChain(tx *iotago.Transaction, chainID isc.ChainID) ([]isc.Request, error) { - env.glbMutex.RLock() - defer env.glbMutex.RUnlock() + env.chainsMutex.RLock() + defer env.chainsMutex.RUnlock() m := env.requestsByChain(tx) ret, ok := m[chainID] @@ -397,18 +399,13 @@ func (env *Solo) requestsByChain(tx *iotago.Transaction) map[isc.ChainID][]isc.R // AddRequestsToMempool adds all the requests to the chain mempool, func (env *Solo) AddRequestsToMempool(ch *Chain, reqs []isc.Request) { - env.glbMutex.RLock() - defer env.glbMutex.RUnlock() - ch.runVMMutex.Lock() - defer ch.runVMMutex.Unlock() - ch.mempool.ReceiveRequests(reqs...) } // EnqueueRequests adds requests contained in the transaction to mempools of respective target chains func (env *Solo) EnqueueRequests(tx *iotago.Transaction) { - env.glbMutex.RLock() - defer env.glbMutex.RUnlock() + env.chainsMutex.RLock() + defer env.chainsMutex.RUnlock() requests := env.requestsByChain(tx) @@ -418,11 +415,7 @@ func (env *Solo) EnqueueRequests(tx *iotago.Transaction) { env.logger.Infof("dispatching requests. Unknown chain: %s", chainID.String()) continue } - ch.runVMMutex.Lock() - ch.mempool.ReceiveRequests(reqs...) - - ch.runVMMutex.Unlock() } } diff --git a/packages/testutil/testdbhash/TestStorageContract.hex b/packages/testutil/testdbhash/TestStorageContract.hex index d87e012d38..09e273042a 100644 --- a/packages/testutil/testdbhash/TestStorageContract.hex +++ b/packages/testutil/testdbhash/TestStorageContract.hex @@ -1 +1 @@ -0x24e3aae46e4ed7ce22ac6eb6baf81fbca6c3777fdaaee73b2dae77a0267d96b1 +0x067c2015d0e63eb81b311fcddc20693668c18b575752786aa0288ce59c820391 diff --git a/packages/vm/core/accounts/foundries.go b/packages/vm/core/accounts/foundries.go index 241b234bd7..d9f0fb9f46 100644 --- a/packages/vm/core/accounts/foundries.go +++ b/packages/vm/core/accounts/foundries.go @@ -29,7 +29,7 @@ func allFoundriesMapR(state kv.KVStoreReader) *collections.ImmutableMap { } // SaveFoundryOutput stores foundry output into the map of all foundry outputs (compressed form) -func SaveFoundryOutput(state kv.KVStore, f *iotago.FoundryOutput, blockIndex uint32, outputIndex uint16) { +func SaveFoundryOutput(state kv.KVStore, f *iotago.FoundryOutput, outputIndex uint16) { foundryRec := foundryOutputRec{ // TransactionID is unknown yet, will be filled next block OutputID: iotago.OutputIDFromTransactionIDAndIndex(iotago.TransactionID{}, outputIndex), diff --git a/packages/vm/core/accounts/impl.go b/packages/vm/core/accounts/impl.go index a513dd8781..082235305d 100644 --- a/packages/vm/core/accounts/impl.go +++ b/packages/vm/core/accounts/impl.go @@ -26,6 +26,7 @@ var Processor = Contract.Processor(nil, FuncFoundryCreateNew.WithHandler(foundryCreateNew), FuncFoundryDestroy.WithHandler(foundryDestroy), FuncFoundryModifySupply.WithHandler(foundryModifySupply), + FuncMintNFT.WithHandler(mintNFT), FuncTransferAccountToChain.WithHandler(transferAccountToChain), FuncTransferAllowanceTo.WithHandler(transferAllowanceTo), FuncWithdraw.WithHandler(withdraw), @@ -35,6 +36,7 @@ var Processor = Contract.Processor(nil, ViewAccountNFTAmount.WithHandler(viewAccountNFTAmount), ViewAccountNFTsInCollection.WithHandler(viewAccountNFTsInCollection), ViewAccountNFTAmountInCollection.WithHandler(viewAccountNFTAmountInCollection), + ViewNFTIDbyMintID.WithHandler(viewNFTIDbyMintID), ViewAccountFoundries.WithHandler(viewAccountFoundries), ViewAccounts.WithHandler(viewAccounts), ViewBalance.WithHandler(viewBalance), diff --git a/packages/vm/core/accounts/impl_views.go b/packages/vm/core/accounts/impl_views.go index fb194866bb..6edb178d96 100644 --- a/packages/vm/core/accounts/impl_views.go +++ b/packages/vm/core/accounts/impl_views.go @@ -116,7 +116,7 @@ func viewAccountNFTs(ctx isc.SandboxView) dict.Dict { func viewAccountNFTAmount(ctx isc.SandboxView) dict.Dict { aid := ctx.Params().MustGetAgentID(ParamAgentID, ctx.Caller()) return dict.Dict{ - ParamNFTAmount: codec.EncodeUint32(nftsMapR(ctx.StateR(), aid).Len()), + ParamNFTAmount: codec.EncodeUint32(accountToNFTsMapR(ctx.StateR(), aid).Len()), } } @@ -155,7 +155,10 @@ func viewAccountNFTAmountInCollection(ctx isc.SandboxView) dict.Dict { func viewNFTData(ctx isc.SandboxView) dict.Dict { ctx.Log().Debugf("accounts.viewNFTData") nftID := ctx.Params().MustGetNFTID(ParamNFTID) - data := MustGetNFTData(ctx.StateR(), nftID) + data := GetNFTData(ctx.StateR(), nftID) + if data == nil { + panic("NFTID not found") + } return dict.Dict{ ParamNFTData: data.Bytes(), } diff --git a/packages/vm/core/accounts/interface.go b/packages/vm/core/accounts/interface.go index 84d161b3fa..6b99bcdf00 100644 --- a/packages/vm/core/accounts/interface.go +++ b/packages/vm/core/accounts/interface.go @@ -12,6 +12,7 @@ var ( FuncFoundryCreateNew = coreutil.Func("foundryCreateNew") FuncFoundryDestroy = coreutil.Func("foundryDestroy") FuncFoundryModifySupply = coreutil.Func("foundryModifySupply") + FuncMintNFT = coreutil.Func("mintNFT") FuncTransferAccountToChain = coreutil.Func("transferAccountToChain") FuncTransferAllowanceTo = coreutil.Func("transferAllowanceTo") FuncWithdraw = coreutil.Func("withdraw") @@ -24,6 +25,7 @@ var ( ViewAccountNFTAmountInCollection = coreutil.ViewFunc("accountNFTAmountInCollection") ViewAccountNFTs = coreutil.ViewFunc("accountNFTs") ViewAccountNFTsInCollection = coreutil.ViewFunc("accountNFTsInCollection") + ViewNFTIDbyMintID = coreutil.ViewFunc("NFTIDbyMintID") ViewAccounts = coreutil.ViewFunc("accounts") ViewBalance = coreutil.ViewFunc("balance") ViewBalanceBaseToken = coreutil.ViewFunc("balanceBaseToken") @@ -50,6 +52,9 @@ const ( ParamNFTData = "e" ParamNFTID = "z" ParamNFTIDs = "i" + ParamNFTImmutableData = "I" + ParamNFTWithdrawOnMint = "w" + ParamMintID = "D" ParamNativeTokenID = "N" ParamSupplyDeltaAbs = "d" ParamTokenScheme = "t" diff --git a/packages/vm/core/accounts/internal.go b/packages/vm/core/accounts/internal.go index a7ca6c258d..3c4db82b28 100644 --- a/packages/vm/core/accounts/internal.go +++ b/packages/vm/core/accounts/internal.go @@ -2,6 +2,7 @@ package accounts import ( "errors" + "fmt" iotago "github.com/iotaledger/iota.go/v3" "github.com/iotaledger/wasp/packages/isc" @@ -42,6 +43,10 @@ const ( PrefixNFTs = "n" // PrefixNFTsByCollection | | stores a map of => true PrefixNFTsByCollection = "c" + // prefixNewlyMintedNFTs stores a map of => to be updated when the outputID is known + prefixNewlyMintedNFTs = "N" + // prefixInternalNFTIDMap stores a map of => it is updated when the NFTID of newly minted nfts is known + prefixInternalNFTIDMap = "M" // PrefixFoundries + stores a map of (uint32) => true PrefixFoundries = "f" @@ -57,8 +62,8 @@ const ( keyFoundryOutputRecords = "FO" // keyNFTOutputRecords stores a map of => NFTOutputRec keyNFTOutputRecords = "NO" - // keyNFTData stores a map of => isc.NFT - keyNFTData = "ND" + // keyNFTOwner stores a map of => isc.AgentID + keyNFTOwner = "NW" // keyNewNativeTokens stores an array of , containing the newly created native tokens that need filling out the OutputID keyNewNativeTokens = "TN" @@ -135,14 +140,14 @@ func MoveBetweenAccounts(state kv.KVStore, fromAgentID, toAgentID isc.AgentID, a creditToAccount(state, accountKey(toAgentID), assets) for _, nftID := range assets.NFTs { - nft, err := getNFTData(state, nftID) - if err != nil { - return err + nft := GetNFTData(state, nftID) + if nft == nil { + return fmt.Errorf("MoveBetweenAccounts: unknown NFT %s", nftID) } if !debitNFTFromAccount(state, fromAgentID, nft) { return errors.New("MoveBetweenAccounts: NFT not found in origin account") } - creditNFTToAccount(state, toAgentID, nft) + creditNFTToAccount(state, toAgentID, nft.ID, nft.Issuer) } touchAccount(state, fromAgentID) @@ -168,8 +173,9 @@ func debitBaseTokensFromAllowance(ctx isc.Sandbox, amount uint64) { DebitFromAccount(ctx.State(), CommonAccount(), storageDepositAssets) } -func UpdateLatestOutputID(state kv.KVStore, anchorTxID iotago.TransactionID) { +func UpdateLatestOutputID(state kv.KVStore, anchorTxID iotago.TransactionID, blockIndex uint32) { updateNativeTokenOutputIDs(state, anchorTxID) updateFoundryOutputIDs(state, anchorTxID) updateNFTOutputIDs(state, anchorTxID) + updateNewlyMintedNFTOutputIDs(state, anchorTxID, blockIndex) } diff --git a/packages/vm/core/accounts/internal_test.go b/packages/vm/core/accounts/internal_test.go index 9191423948..0708b52deb 100644 --- a/packages/vm/core/accounts/internal_test.go +++ b/packages/vm/core/accounts/internal_test.go @@ -309,14 +309,32 @@ func TestTransferNFTs(t *testing.T) { Issuer: tpkg.RandEd25519Address(), Metadata: []byte("foobar"), } - CreditNFTToAccount(state, agentID1, NFT1) + CreditNFTToAccount(state, agentID1, &iotago.NFTOutput{ + Amount: 0, + NativeTokens: []*iotago.NativeToken{}, + NFTID: NFT1.ID, + ImmutableFeatures: []iotago.Feature{ + &iotago.IssuerFeature{Address: NFT1.Issuer}, + &iotago.MetadataFeature{Data: NFT1.Metadata}, + }, + }) // nft is credited user1NFTs := getAccountNFTs(state, agentID1) require.Len(t, user1NFTs, 1) require.Equal(t, user1NFTs[0], NFT1.ID) - // nft data is saved - nftData := MustGetNFTData(state, NFT1.ID) + // nft data is saved (accounts.SaveNFTOutput must be called) + SaveNFTOutput(state, &iotago.NFTOutput{ + Amount: 0, + NativeTokens: []*iotago.NativeToken{}, + NFTID: NFT1.ID, + ImmutableFeatures: []iotago.Feature{ + &iotago.IssuerFeature{Address: NFT1.Issuer}, + &iotago.MetadataFeature{Data: NFT1.Metadata}, + }, + }, 0) + + nftData := GetNFTData(state, NFT1.ID) require.Equal(t, nftData.ID, NFT1.ID) require.Equal(t, nftData.Issuer, NFT1.Issuer) require.Equal(t, nftData.Metadata, NFT1.Metadata) @@ -338,7 +356,7 @@ func TestTransferNFTs(t *testing.T) { // remove the NFT from the chain DebitNFTFromAccount(state, agentID2, NFT1.ID) require.Panics(t, func() { - MustGetNFTData(state, NFT1.ID) + GetNFTData(state, NFT1.ID) }) } @@ -366,7 +384,15 @@ func TestCreditDebitNFT1(t *testing.T) { Issuer: tpkg.RandEd25519Address(), Metadata: []byte("foobar"), } - CreditNFTToAccount(state, agentID1, &nft) + CreditNFTToAccount(state, agentID1, &iotago.NFTOutput{ + Amount: 0, + NativeTokens: []*iotago.NativeToken{}, + NFTID: nft.ID, + ImmutableFeatures: []iotago.Feature{ + &iotago.IssuerFeature{Address: nft.Issuer}, + &iotago.MetadataFeature{Data: nft.Metadata}, + }, + }) accNFTs := GetAccountNFTs(state, agentID1) require.Len(t, accNFTs, 1) diff --git a/packages/vm/core/accounts/nativetokenoutputs.go b/packages/vm/core/accounts/nativetokenoutputs.go index 36b93db728..5e5f991bdf 100644 --- a/packages/vm/core/accounts/nativetokenoutputs.go +++ b/packages/vm/core/accounts/nativetokenoutputs.go @@ -21,7 +21,7 @@ func nativeTokenOutputMapR(state kv.KVStoreReader) *collections.ImmutableMap { } // SaveNativeTokenOutput map nativeTokenID -> foundryRec -func SaveNativeTokenOutput(state kv.KVStore, out *iotago.BasicOutput, blockIndex uint32, outputIndex uint16) { +func SaveNativeTokenOutput(state kv.KVStore, out *iotago.BasicOutput, outputIndex uint16) { tokenRec := nativeTokenOutputRec{ // TransactionID is unknown yet, will be filled next block OutputID: iotago.OutputIDFromTransactionIDAndIndex(iotago.TransactionID{}, outputIndex), diff --git a/packages/vm/core/accounts/nftmint.go b/packages/vm/core/accounts/nftmint.go new file mode 100644 index 0000000000..be70838948 --- /dev/null +++ b/packages/vm/core/accounts/nftmint.go @@ -0,0 +1,208 @@ +package accounts + +import ( + "io" + + iotago "github.com/iotaledger/iota.go/v3" + "github.com/iotaledger/wasp/packages/isc" + "github.com/iotaledger/wasp/packages/kv" + "github.com/iotaledger/wasp/packages/kv/codec" + "github.com/iotaledger/wasp/packages/kv/collections" + "github.com/iotaledger/wasp/packages/kv/dict" + "github.com/iotaledger/wasp/packages/util/rwutil" + "github.com/iotaledger/wasp/packages/vm/core/errors/coreerrors" +) + +type mintedNFTRecord struct { + positionInMintedList uint16 + outputIndex uint16 + owner isc.AgentID + output *iotago.NFTOutput +} + +func (rec *mintedNFTRecord) Read(r io.Reader) error { + rr := rwutil.NewReader(r) + rec.positionInMintedList = rr.ReadUint16() + rec.outputIndex = rr.ReadUint16() + rec.owner = isc.AgentIDFromReader(rr) + rec.output = new(iotago.NFTOutput) + rr.ReadSerialized(rec.output) + return rr.Err +} + +func (rec *mintedNFTRecord) Write(w io.Writer) error { + ww := rwutil.NewWriter(w) + ww.WriteUint16(rec.positionInMintedList) + ww.WriteUint16(rec.outputIndex) + if rec.owner != nil { + ww.Write(rec.owner) + } else { + ww.Write(&isc.NilAgentID{}) + } + ww.WriteSerialized(rec.output) + return ww.Err +} + +func (rec *mintedNFTRecord) Bytes() []byte { + return rwutil.WriteToBytes(rec) +} + +func mintedNFTRecordFromBytes(data []byte) *mintedNFTRecord { + record, err := rwutil.ReadFromBytes(data, new(mintedNFTRecord)) + if err != nil { + panic(err) + } + return record +} + +func newlyMintedNFTsMap(state kv.KVStore) *collections.Map { + return collections.NewMap(state, prefixNewlyMintedNFTs) +} + +func mintIDMap(state kv.KVStore) *collections.Map { + return collections.NewMap(state, prefixInternalNFTIDMap) +} + +func mintIDMapR(state kv.KVStoreReader) *collections.ImmutableMap { + return collections.NewMapReadOnly(state, prefixInternalNFTIDMap) +} + +var ( + errMintNFTWithdraw = coreerrors.Register("can only withdraw on mint to a L1 address").Create() + errInvalidAgentID = coreerrors.Register("invalid agentID").Create() + errCollectionNotAllowed = coreerrors.Register("caller doesn't own the collection").Create() +) + +type mintParameters struct { + immutableMetadata []byte + targetAddress iotago.Address + issuerAddress iotago.Address + ownerAgentID isc.AgentID + withdrawOnMint bool +} + +func mintParams(ctx isc.Sandbox) mintParameters { + params := ctx.Params() + + immutableMetadata := params.MustGetBytes(ParamNFTImmutableData) + targetAgentID := params.MustGetAgentID(ParamAgentID) + withdrawOnMint := params.MustGetBool(ParamNFTWithdrawOnMint, false) + emptyNFTID := iotago.NFTID{} + collectionID := params.MustGetNFTID(ParamCollectionID, emptyNFTID) + + chainAddress := ctx.ChainID().AsAddress() + ret := mintParameters{ + immutableMetadata: immutableMetadata, + targetAddress: chainAddress, + issuerAddress: chainAddress, + ownerAgentID: targetAgentID, + withdrawOnMint: withdrawOnMint, + } + + if collectionID != emptyNFTID { + // assert the NFT of collectionID is on-chain and owned by the caller + if !hasNFT(ctx.State(), ctx.Caller(), collectionID) { + panic(errCollectionNotAllowed) + } + ret.issuerAddress = collectionID.ToAddress() + } + + switch targetAgentID.Kind() { + case isc.AgentIDKindContract, isc.AgentIDKindEthereumAddress: + if withdrawOnMint { + panic(errMintNFTWithdraw) + } + return ret + case isc.AgentIDKindAddress: + if withdrawOnMint { + ret.targetAddress = targetAgentID.(*isc.AddressAgentID).Address() + return ret + } + return ret + default: + panic(errInvalidAgentID) + } +} + +func internalNFTID(blockIndex uint32, positionInMintedList uint16) []byte { + ret := make([]byte, 6) + copy(ret[0:], codec.EncodeUint32(blockIndex)) + copy(ret[4:], codec.EncodeUint16(positionInMintedList)) + return ret +} + +// NFTs are always minted with the minimumSD and that must be provided via allowance +func mintNFT(ctx isc.Sandbox) dict.Dict { + params := mintParams(ctx) + + positionInMintedList, nftOutput := ctx.Privileged().MintNFT( + params.targetAddress, + params.immutableMetadata, + params.issuerAddress, + ) + + // debit the SD required for the NFT from the sender account + ctx.TransferAllowedFunds(ctx.AccountID(), isc.NewAssetsBaseTokens(nftOutput.Amount)) // claim tokens from allowance + DebitFromAccount(ctx.State(), ctx.AccountID(), isc.NewAssetsBaseTokens(nftOutput.Amount)) // debit from this SC account + + rec := mintedNFTRecord{ + positionInMintedList: positionInMintedList, + outputIndex: 0, // to be filled on block close by `SaveMintedNFTOutput` + owner: params.ownerAgentID, + output: nftOutput, + } + // save the info required to credit the NFT on next block + newlyMintedNFTsMap(ctx.State()).SetAt(codec.Encode(positionInMintedList), rec.Bytes()) + + return dict.Dict{ + ParamMintID: internalNFTID(ctx.StateAnchor().StateIndex+1, positionInMintedList), + } +} + +func viewNFTIDbyMintID(ctx isc.SandboxView) dict.Dict { + internalMintID := ctx.Params().MustGetBytes(ParamMintID) + nftID := mintIDMapR(ctx.StateR()).GetAt(internalMintID) + return dict.Dict{ + ParamNFTID: nftID, + } +} + +// ---- output management + +func SaveMintedNFTOutput(state kv.KVStore, positionInMintedList, outputIndex uint16) { + mintMap := newlyMintedNFTsMap(state) + key := codec.Encode(positionInMintedList) + recBytes := mintMap.GetAt(key) + if recBytes == nil { + return + } + rec := mintedNFTRecordFromBytes(recBytes) + rec.outputIndex = outputIndex + mintMap.SetAt(key, rec.Bytes()) +} + +func updateNewlyMintedNFTOutputIDs(state kv.KVStore, anchorTxID iotago.TransactionID, blockIndex uint32) { + mintMap := newlyMintedNFTsMap(state) + nftMap := NFTOutputMap(state) + mintMap.Iterate(func(_, recBytes []byte) bool { + mintedRec := mintedNFTRecordFromBytes(recBytes) + // calculate the NFTID from the anchor txID + outputIndex + outputID := iotago.OutputIDFromTransactionIDAndIndex(anchorTxID, mintedRec.outputIndex) + nftID := iotago.NFTIDFromOutputID(outputID) + + if mintedRec.owner.Kind() != isc.AgentIDKindNil { // when owner is nil, means the NFT was minted directly to a L1 wallet + outputRec := NFTOutputRec{ + OutputID: outputID, + Output: mintedRec.output, + } + // save the updated data in the NFT map + nftMap.SetAt(nftID[:], outputRec.Bytes()) + // credit the NFT to the target owner + creditNFTToAccount(state, mintedRec.owner, nftID, mintedRec.output.ImmutableFeatureSet().IssuerFeature().Address) + } + // save the mapping of [internalID => NFTID] + mintIDMap(state).SetAt(internalNFTID(blockIndex, mintedRec.positionInMintedList), nftID[:]) + return true + }) + mintMap.Erase() +} diff --git a/packages/vm/core/accounts/nftoutputs.go b/packages/vm/core/accounts/nftoutputs.go index e4fec899f4..cfd49d2aff 100644 --- a/packages/vm/core/accounts/nftoutputs.go +++ b/packages/vm/core/accounts/nftoutputs.go @@ -18,8 +18,7 @@ func nftOutputMapR(state kv.KVStoreReader) *collections.ImmutableMap { return collections.NewMapReadOnly(state, keyNFTOutputRecords) } -// SaveNFTOutput map tokenID -> foundryRec -func SaveNFTOutput(state kv.KVStore, out *iotago.NFTOutput, blockIndex uint32, outputIndex uint16) { +func SaveNFTOutput(state kv.KVStore, out *iotago.NFTOutput, outputIndex uint16) { tokenRec := NFTOutputRec{ // TransactionID is unknown yet, will be filled next block OutputID: iotago.OutputIDFromTransactionIDAndIndex(iotago.TransactionID{}, outputIndex), @@ -34,20 +33,20 @@ func updateNFTOutputIDs(state kv.KVStore, anchorTxID iotago.TransactionID) { allNFTs := NFTOutputMap(state) n := newNFTs.Len() for i := uint32(0); i < n; i++ { - k := newNFTs.GetAt(i) - rec := mustNFTOutputRecFromBytes(allNFTs.GetAt(k)) + nftID := newNFTs.GetAt(i) + rec := mustNFTOutputRecFromBytes(allNFTs.GetAt(nftID)) rec.OutputID = iotago.OutputIDFromTransactionIDAndIndex(anchorTxID, rec.OutputID.Index()) - allNFTs.SetAt(k, rec.Bytes()) + allNFTs.SetAt(nftID, rec.Bytes()) } newNFTs.Erase() } -func DeleteNFTOutput(state kv.KVStore, id iotago.NFTID) { - NFTOutputMap(state).DelAt(id[:]) +func DeleteNFTOutput(state kv.KVStore, nftID iotago.NFTID) { + NFTOutputMap(state).DelAt(nftID[:]) } -func GetNFTOutput(state kv.KVStoreReader, id iotago.NFTID) (*iotago.NFTOutput, iotago.OutputID) { - data := nftOutputMapR(state).GetAt(id[:]) +func GetNFTOutput(state kv.KVStoreReader, nftID iotago.NFTID) (*iotago.NFTOutput, iotago.OutputID) { + data := nftOutputMapR(state).GetAt(nftID[:]) if data == nil { return nil, iotago.OutputID{} } diff --git a/packages/vm/core/accounts/nfts.go b/packages/vm/core/accounts/nfts.go index 17f2bb84a2..724f32a1db 100644 --- a/packages/vm/core/accounts/nfts.go +++ b/packages/vm/core/accounts/nfts.go @@ -8,7 +8,7 @@ import ( "github.com/iotaledger/wasp/packages/kv" "github.com/iotaledger/wasp/packages/kv/codec" "github.com/iotaledger/wasp/packages/kv/collections" - "github.com/iotaledger/wasp/packages/util/rwutil" + "github.com/iotaledger/wasp/packages/util" ) func nftsMapKey(agentID isc.AgentID) string { @@ -23,20 +23,20 @@ func foundriesMapKey(agentID isc.AgentID) string { return PrefixFoundries + string(agentID.Bytes()) } -func nftsMapR(state kv.KVStoreReader, agentID isc.AgentID) *collections.ImmutableMap { +func accountToNFTsMapR(state kv.KVStoreReader, agentID isc.AgentID) *collections.ImmutableMap { return collections.NewMapReadOnly(state, nftsMapKey(agentID)) } -func nftsMap(state kv.KVStore, agentID isc.AgentID) *collections.Map { +func accountToNFTsMap(state kv.KVStore, agentID isc.AgentID) *collections.Map { return collections.NewMap(state, nftsMapKey(agentID)) } -func NFTDataMap(state kv.KVStore) *collections.Map { - return collections.NewMap(state, keyNFTData) +func NFTToOwnerMap(state kv.KVStore) *collections.Map { + return collections.NewMap(state, keyNFTOwner) } -func nftDataMapR(state kv.KVStoreReader) *collections.ImmutableMap { - return collections.NewMapReadOnly(state, keyNFTData) +func NFTToOwnerMapR(state kv.KVStoreReader) *collections.ImmutableMap { + return collections.NewMapReadOnly(state, keyNFTOwner) } func nftCollectionKey(issuer iotago.Address) kv.Key { @@ -60,92 +60,92 @@ func nftsByCollectionMap(state kv.KVStore, agentID isc.AgentID, collectionKey kv } func hasNFT(state kv.KVStoreReader, agentID isc.AgentID, nftID iotago.NFTID) bool { - return nftsMapR(state, agentID).HasAt(nftID[:]) + return accountToNFTsMapR(state, agentID).HasAt(nftID[:]) } -func saveNFTData(state kv.KVStore, nft *isc.NFT) { - ww := rwutil.NewBytesWriter() - // note we store the NFT data without the leading id bytes - ww.Skip().ReadN(nft.ID[:]) - ww.Write(nft) - NFTDataMap(state).SetAt(nft.ID[:], ww.Bytes()) -} +func removeNFTOwner(state kv.KVStore, nftID iotago.NFTID, agentID isc.AgentID) bool { + // remove the mapping of NFTID => owner + nftMap := NFTToOwnerMap(state) + if !nftMap.HasAt(nftID[:]) { + return false + } + nftMap.DelAt(nftID[:]) -func deleteNFTData(state kv.KVStore, id iotago.NFTID) { - allNFTs := NFTDataMap(state) - if !allNFTs.HasAt(id[:]) { - panic("deleteNFTData: inconsistency - NFT data doesn't exists") + // add to the mapping of agentID => []NFTIDs + nfts := accountToNFTsMap(state, agentID) + if !nfts.HasAt(nftID[:]) { + return false } - allNFTs.DelAt(id[:]) + nfts.DelAt(nftID[:]) + return true } -func getNFTData(state kv.KVStoreReader, id iotago.NFTID) (*isc.NFT, error) { - allNFTs := nftDataMapR(state) - nftData := allNFTs.GetAt(id[:]) - if len(nftData) == 0 { - return nil, ErrNFTIDNotFound - } +func setNFTOwner(state kv.KVStore, nftID iotago.NFTID, agentID isc.AgentID) { + // add to the mapping of NFTID => owner + nftMap := NFTToOwnerMap(state) + nftMap.SetAt(nftID[:], agentID.Bytes()) - rr := rwutil.NewBytesReader(nftData) - // note we stored the NFT data without the leading id bytes - rr.PushBack().WriteN(id[:]) - return isc.NFTFromReader(rr) + // add to the mapping of agentID => []NFTIDs + nfts := accountToNFTsMap(state, agentID) + nfts.SetAt(nftID[:], codec.EncodeBool(true)) } -func MustGetNFTData(state kv.KVStoreReader, id iotago.NFTID) *isc.NFT { - nft, err := getNFTData(state, id) +func GetNFTData(state kv.KVStoreReader, nftID iotago.NFTID) *isc.NFT { + o, oID := GetNFTOutput(state, nftID) + if o == nil { + return nil + } + owner, err := isc.AgentIDFromBytes(NFTToOwnerMapR(state).GetAt(nftID[:])) if err != nil { - panic(err) + panic("error parsing AgentID in NFTToOwnerMap") + } + return &isc.NFT{ + ID: util.NFTIDFromNFTOutput(o, oID), + Issuer: o.ImmutableFeatureSet().IssuerFeature().Address, + Metadata: o.ImmutableFeatureSet().MetadataFeature().Data, + Owner: owner, } - return nft } // CreditNFTToAccount credits an NFT to the on chain ledger -func CreditNFTToAccount(state kv.KVStore, agentID isc.AgentID, nft *isc.NFT) { - if nft == nil { - return - } - if nft.ID.Empty() { +func CreditNFTToAccount(state kv.KVStore, agentID isc.AgentID, nftOutput *iotago.NFTOutput) { + if nftOutput.NFTID.Empty() { panic("empty NFTID") } - creditNFTToAccount(state, agentID, nft) + creditNFTToAccount(state, agentID, nftOutput.NFTID, nftOutput.ImmutableFeatureSet().IssuerFeature().Address) touchAccount(state, agentID) -} -func creditNFTToAccount(state kv.KVStore, agentID isc.AgentID, nft *isc.NFT) { - nft.Owner = agentID - saveNFTData(state, nft) + // save the NFTOutput with a temporary outputIndex so the NFTData is readily available (it will be updated upon block closing) + SaveNFTOutput(state, nftOutput, 0) +} - nfts := nftsMap(state, agentID) - nfts.SetAt(nft.ID[:], codec.EncodeBool(true)) +func creditNFTToAccount(state kv.KVStore, agentID isc.AgentID, nftID iotago.NFTID, issuer iotago.Address) { + setNFTOwner(state, nftID, agentID) - collectionKey := nftCollectionKey(nft.Issuer) + collectionKey := nftCollectionKey(issuer) nftsByCollection := nftsByCollectionMap(state, agentID, collectionKey) - nftsByCollection.SetAt(nft.ID[:], codec.EncodeBool(true)) + nftsByCollection.SetAt(nftID[:], codec.EncodeBool(true)) } // DebitNFTFromAccount removes an NFT from an account. // If the account does not own the nft, it panics. -func DebitNFTFromAccount(state kv.KVStore, agentID isc.AgentID, id iotago.NFTID) { - nft, err := getNFTData(state, id) - if err != nil { - panic(err) +func DebitNFTFromAccount(state kv.KVStore, agentID isc.AgentID, nftID iotago.NFTID) { + nft := GetNFTData(state, nftID) + if nft == nil { + panic(fmt.Errorf("cannot debit unknown NFT %s", nftID.String())) } if !debitNFTFromAccount(state, agentID, nft) { - panic(fmt.Errorf("cannot debit NFT from %s: %w", agentID, ErrNotEnoughFunds)) + panic(fmt.Errorf("cannot debit NFT %s from %s: %w", nftID.String(), agentID, ErrNotEnoughFunds)) } - deleteNFTData(state, id) touchAccount(state, agentID) } // DebitNFTFromAccount removes an NFT from the internal map of an account func debitNFTFromAccount(state kv.KVStore, agentID isc.AgentID, nft *isc.NFT) bool { - nfts := nftsMap(state, agentID) - if !nfts.HasAt(nft.ID[:]) { + if !removeNFTOwner(state, nft.ID, agentID) { return false } - nfts.DelAt(nft.ID[:]) collectionKey := nftCollectionKey(nft.Issuer) nftsByCollection := nftsByCollectionMap(state, agentID, collectionKey) @@ -169,7 +169,7 @@ func collectNFTIDs(m *collections.ImmutableMap) []iotago.NFTID { } func getAccountNFTs(state kv.KVStoreReader, agentID isc.AgentID) []iotago.NFTID { - return collectNFTIDs(nftsMapR(state, agentID)) + return collectNFTIDs(accountToNFTsMapR(state, agentID)) } func getAccountNFTsInCollection(state kv.KVStoreReader, agentID isc.AgentID, collectionID iotago.NFTID) []iotago.NFTID { @@ -177,7 +177,7 @@ func getAccountNFTsInCollection(state kv.KVStoreReader, agentID isc.AgentID, col } func getL2TotalNFTs(state kv.KVStoreReader) []iotago.NFTID { - return collectNFTIDs(nftDataMapR(state)) + return collectNFTIDs(NFTToOwnerMapR(state)) } // GetAccountNFTs returns all NFTs belonging to the agentID on the state diff --git a/packages/vm/core/blocklog/unprocessable.go b/packages/vm/core/blocklog/unprocessable.go index cd44160389..ec6aa2f058 100644 --- a/packages/vm/core/blocklog/unprocessable.go +++ b/packages/vm/core/blocklog/unprocessable.go @@ -130,7 +130,8 @@ func retryUnprocessable(ctx isc.Sandbox) dict.Dict { if err != nil { panic(ErrUnprocessableUnexpected) } - if !rec.SenderAccount().Equals(ctx.Request().SenderAccount()) { + recSender := rec.SenderAccount() + if rec.SenderAccount() == nil || !recSender.Equals(ctx.Request().SenderAccount()) { panic(ErrUnprocessableWrongSender) } ctx.Privileged().RetryUnprocessable(rec, outputID) diff --git a/packages/vm/core/migrations/allmigrations/all.go b/packages/vm/core/migrations/allmigrations/all.go index b492742579..579c7e5ad1 100644 --- a/packages/vm/core/migrations/allmigrations/all.go +++ b/packages/vm/core/migrations/allmigrations/all.go @@ -3,6 +3,7 @@ package allmigrations import ( "github.com/iotaledger/wasp/packages/vm/core/migrations" "github.com/iotaledger/wasp/packages/vm/core/migrations/m001" + "github.com/iotaledger/wasp/packages/vm/core/migrations/m002" ) var DefaultScheme = &migrations.MigrationScheme{ @@ -17,5 +18,6 @@ var DefaultScheme = &migrations.MigrationScheme{ // BaseSchemaVersion by one. Migrations: []migrations.Migration{ m001.ResetAccountAssets, + m002.DeprecateNFTData, }, } diff --git a/packages/vm/core/migrations/m001/reset_account_assets.go b/packages/vm/core/migrations/m001/reset_account_assets.go index 3b17f46f5d..6e7a42f7c2 100644 --- a/packages/vm/core/migrations/m001/reset_account_assets.go +++ b/packages/vm/core/migrations/m001/reset_account_assets.go @@ -20,7 +20,7 @@ var ResetAccountAssets = migrations.Migration{ erasePrefix(accountsState, accounts.PrefixNFTs) erasePrefix(accountsState, accounts.PrefixNFTsByCollection) accounts.NFTOutputMap(accountsState).Erase() - accounts.NFTDataMap(accountsState).Erase() + accounts.NFTToOwnerMap(accountsState).Erase() return nil }, } diff --git a/packages/vm/core/migrations/m002/deprecate_nftdata.go b/packages/vm/core/migrations/m002/deprecate_nftdata.go new file mode 100644 index 0000000000..e6a940274d --- /dev/null +++ b/packages/vm/core/migrations/m002/deprecate_nftdata.go @@ -0,0 +1,36 @@ +package m002 + +import ( + "github.com/iotaledger/hive.go/logger" + iotago "github.com/iotaledger/iota.go/v3" + "github.com/iotaledger/wasp/packages/isc" + "github.com/iotaledger/wasp/packages/kv" + "github.com/iotaledger/wasp/packages/kv/collections" + "github.com/iotaledger/wasp/packages/util/rwutil" + "github.com/iotaledger/wasp/packages/vm/core/accounts" + "github.com/iotaledger/wasp/packages/vm/core/migrations" +) + +// for testnet -- delete when deploying ShimmerEVM +var DeprecateNFTData = migrations.Migration{ + Contract: accounts.Contract, + Apply: func(state kv.KVStore, log *logger.Logger) error { + oldNFTDataMap := collections.NewMap(state, "ND") + nftToOwnerMap := collections.NewMap(state, "NW") + oldNFTDataMap.Iterate(func(nftIDBytes, nftDataBytes []byte) bool { + rr := rwutil.NewBytesReader(nftDataBytes) + // note we stored the NFT data without the leading id bytes + rr.PushBack().WriteN(nftIDBytes) + nft, err := isc.NFTFromReader(rr) + if err != nil { + panic(err) + } + if nft.Owner == nil { + log.Errorf("DeprecateNFTData migration | nil owner at NFTID: %s", iotago.EncodeHex(nftIDBytes)) + } + nftToOwnerMap.SetAt(nftIDBytes, nft.Owner.Bytes()) + return true + }) + return nil + }, +} diff --git a/packages/vm/core/migrations/m002/deprecate_nftdata_test.go b/packages/vm/core/migrations/m002/deprecate_nftdata_test.go new file mode 100644 index 0000000000..ef43a68508 --- /dev/null +++ b/packages/vm/core/migrations/m002/deprecate_nftdata_test.go @@ -0,0 +1,51 @@ +package m002_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/iotaledger/wasp/packages/isc" + "github.com/iotaledger/wasp/packages/kv/dict" + "github.com/iotaledger/wasp/packages/solo" + "github.com/iotaledger/wasp/packages/vm/core/accounts" + "github.com/iotaledger/wasp/packages/vm/core/migrations/m002" +) + +func TestM002Migration(t *testing.T) { + // skipping, no need to store the snapshot and run this test after the migration is applied + t.SkipNow() + + env := solo.New(t, &solo.InitOptions{AutoAdjustStorageDeposit: true, Debug: true, PrintStackTrace: true}) + + // the snapshot is from commit 54d70ac + // created by running TestSaveSnapshot in packages/solo/solotest + env.LoadSnapshot("snapshot.db") + + ch := env.GetChainByName("chain1") + + require.EqualValues(t, 5, ch.LatestBlockIndex()) + + // add the migration to test + ch.AddMigration(m002.DeprecateNFTData) + + // cause a VM run, which will run the migration + err := ch.DepositBaseTokensToL2(1000, ch.OriginatorPrivateKey) + require.NoError(t, err) + + // in the snapshot the OriginatorAgentID owns 1 NFT + assets := ch.L2Assets(ch.OriginatorAgentID) + require.Len(t, assets.NFTs, 1) + nftID := assets.NFTs[0] + + // can still query the data of the NFT + ret, err := ch.CallView(accounts.Contract.Name, accounts.ViewNFTData.Name, dict.Dict{ + accounts.ParamNFTID: nftID[:], + }) + require.NoError(t, err) + nft, err := isc.NFTFromBytes(ret.Get(accounts.ParamNFTData)) + require.NoError(t, err) + require.NotNil(t, nft.Owner) + require.NotNil(t, nft.Issuer) + require.NotNil(t, nft.Metadata) +} diff --git a/packages/vm/core/testcore/accounts_test.go b/packages/vm/core/testcore/accounts_test.go index 50033161e0..76b85e6ebb 100644 --- a/packages/vm/core/testcore/accounts_test.go +++ b/packages/vm/core/testcore/accounts_test.go @@ -10,6 +10,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/samber/lo" "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" iotago "github.com/iotaledger/iota.go/v3" "github.com/iotaledger/iota.go/v3/tpkg" @@ -1429,3 +1430,137 @@ func TestNonces(t *testing.T) { _, err = ch.PostRequestOffLedger(req, senderWallet) testmisc.RequireErrorToBe(t, err, vm.ErrContractNotFound) } + +func TestNFTMint(t *testing.T) { + env := solo.New(t) + ch := env.NewChain() + + t.Run("mint for another user", func(t *testing.T) { + wallet, _ := env.NewKeyPairWithFunds() + anotherUserAgentID := isc.NewAgentID(tpkg.RandEd25519Address()) + + // mint NFT to another user and keep it on chain + req := solo.NewCallParams( + accounts.Contract.Name, accounts.FuncMintNFT.Name, + accounts.ParamNFTImmutableData, []byte("foobar"), + accounts.ParamAgentID, anotherUserAgentID.Bytes(), + ). + AddBaseTokens(2 * isc.Million). + WithAllowance(isc.NewAssetsBaseTokens(1 * isc.Million)). + WithMaxAffordableGasBudget() + + require.Len(t, ch.L2NFTs(anotherUserAgentID), 0) + _, err := ch.PostRequestSync(req, wallet) + require.NoError(t, err) + + // post a dummy request to make the chain progress to the next block + ch.PostRequestOffLedger(solo.NewCallParams("foo", "bar"), wallet) + require.Len(t, ch.L2NFTs(anotherUserAgentID), 1) + }) + + t.Run("mint for another user, directly to outside the chain", func(t *testing.T) { + wallet, _ := env.NewKeyPairWithFunds() + + anotherUserAddr := tpkg.RandEd25519Address() + anotherUserAgentID := isc.NewAgentID(anotherUserAddr) + + // mint NFT to another user and withdraw it + req := solo.NewCallParams( + accounts.Contract.Name, accounts.FuncMintNFT.Name, + accounts.ParamNFTImmutableData, []byte("foobar"), + accounts.ParamAgentID, anotherUserAgentID.Bytes(), + accounts.ParamNFTWithdrawOnMint, codec.Encode(true), + ). + AddBaseTokens(2 * isc.Million). + WithAllowance(isc.NewAssetsBaseTokens(1 * isc.Million)). + WithMaxAffordableGasBudget() + + require.Len(t, env.L1NFTs(anotherUserAddr), 0) + ret, err := ch.PostRequestSync(req, wallet) + mintID := ret.Get(accounts.ParamMintID) + require.NoError(t, err) + require.Len(t, ch.L2NFTs(anotherUserAgentID), 0) + userL1NFTs := env.L1NFTs(anotherUserAddr) + NFTID := iotago.NFTIDFromOutputID(lo.Keys(userL1NFTs)[0]) + require.Len(t, userL1NFTs, 1) + + // post a dummy request to make the chain progress to the next block + ch.PostRequestOffLedger(solo.NewCallParams("foo", "bar"), wallet) + + // check that the internal ID mapping matches the L1 NFT + ret, err = ch.CallView(accounts.Contract.Name, accounts.ViewNFTIDbyMintID.Name, + accounts.ParamMintID, mintID) + require.NoError(t, err) + storedNFTID := ret.Get(accounts.ParamNFTID) + require.True(t, slices.Equal(storedNFTID, NFTID[:])) + }) + + t.Run("mint to self, then mint from it as a collection", func(t *testing.T) { + wallet, address := env.NewKeyPairWithFunds() + agentID := isc.NewAgentID(address) + + // mint NFT to self and keep it on chain + req := solo.NewCallParams( + accounts.Contract.Name, accounts.FuncMintNFT.Name, + accounts.ParamNFTImmutableData, []byte("foobar"), + accounts.ParamAgentID, agentID.Bytes(), + ). + AddBaseTokens(2 * isc.Million). + WithAllowance(isc.NewAssetsBaseTokens(1 * isc.Million)). + WithMaxAffordableGasBudget() + + require.Len(t, ch.L2NFTs(agentID), 0) + _, err := ch.PostRequestSync(req, wallet) + require.NoError(t, err) + + // post a dummy request to make the chain progress to the next block + ch.PostRequestOffLedger(solo.NewCallParams("foo", "bar"), wallet) + require.Len(t, env.L1NFTs(address), 0) + userL2NFTs := ch.L2NFTs(agentID) + require.Len(t, userL2NFTs, 1) + + // try minting another NFT using the first one as the collection + fistNFTID := userL2NFTs[0] + + req = solo.NewCallParams( + accounts.Contract.Name, accounts.FuncMintNFT.Name, + accounts.ParamNFTImmutableData, []byte("foobar_collection"), + accounts.ParamAgentID, agentID.Bytes(), + accounts.ParamCollectionID, codec.Encode(fistNFTID), + ). + AddBaseTokens(2 * isc.Million). + WithAllowance(isc.NewAssetsBaseTokens(1 * isc.Million)). + WithMaxAffordableGasBudget() + + ret, err := ch.PostRequestSync(req, wallet) + require.NoError(t, err) + mintID := ret.Get(accounts.ParamMintID) + + // post a dummy request to make the chain progress to the next block + ch.PostRequestOffLedger(solo.NewCallParams("foo", "bar"), wallet) + + ret, err = ch.CallView(accounts.Contract.Name, accounts.ViewNFTIDbyMintID.Name, + accounts.ParamMintID, mintID) + require.NoError(t, err) + NFTIDInCollection := ret.Get(accounts.ParamNFTID) + + ret, err = ch.CallView(accounts.Contract.Name, accounts.ViewNFTData.Name, + accounts.ParamNFTID, NFTIDInCollection) + require.NoError(t, err) + + nftData, err := isc.NFTFromBytes(ret.Get(accounts.ParamNFTData)) + require.NoError(t, err) + require.True(t, nftData.Issuer.Equal(fistNFTID.ToAddress())) + require.True(t, nftData.Owner.Equals(agentID)) + + // withdraw both NFTs + err = ch.Withdraw(isc.NewEmptyAssets().AddNFTs(fistNFTID), wallet) + require.NoError(t, err) + + err = ch.Withdraw(isc.NewEmptyAssets().AddNFTs(iotago.NFTID(NFTIDInCollection)), wallet) + require.NoError(t, err) + + require.Len(t, env.L1NFTs(address), 2) + require.Len(t, ch.L2NFTs(agentID), 0) + }) +} diff --git a/packages/vm/core/testcore/custom_onledger_requests_test.go b/packages/vm/core/testcore/custom_onledger_requests_test.go index dd2e71ecda..375c01c0b1 100644 --- a/packages/vm/core/testcore/custom_onledger_requests_test.go +++ b/packages/vm/core/testcore/custom_onledger_requests_test.go @@ -106,7 +106,7 @@ func TestNoSenderFeature(t *testing.T) { reqs, err := ch.Env.RequestsForChain(tx, ch.ChainID) require.NoError(ch.Env.T, err) - results := ch.RunRequestsSync(reqs, "post") + results := ch.RunRequestsSync(reqs, "post") // under normal circumstances this request won't reach the mempool require.Len(t, results, 1) require.NotNil(t, results[0].Receipt.Error) err = ch.ResolveVMError(results[0].Receipt.Error) diff --git a/packages/vm/core/testcore/sbtests/send_test.go b/packages/vm/core/testcore/sbtests/send_test.go index 3e282a3bb7..d94d5aad7d 100644 --- a/packages/vm/core/testcore/sbtests/send_test.go +++ b/packages/vm/core/testcore/sbtests/send_test.go @@ -247,8 +247,6 @@ func testSendNFTsBack(t *testing.T, w bool) { require.True(t, ch.Env.HasL1NFT(addr, &nft.ID)) } -// TODO add a test that makes sure sending more than 4 NFTs out fails with (too many outputs produced) - func TestNFTOffledgerWithdraw(t *testing.T) { run2(t, testNFTOffledgerWithdraw) } func testNFTOffledgerWithdraw(t *testing.T, w bool) { diff --git a/packages/vm/gas/feepolicy.go b/packages/vm/gas/feepolicy.go index 607ef6dbad..619d932b0b 100644 --- a/packages/vm/gas/feepolicy.go +++ b/packages/vm/gas/feepolicy.go @@ -60,6 +60,9 @@ func (p *FeePolicy) FeeFromGas(gasUnits uint64) uint64 { } func (p *FeePolicy) MinFee() uint64 { + if p.GasPerToken.A == 0 { + return 0 + } return p.FeeFromGas(BurnCodeMinimumGasPerRequest1P.Cost()) } diff --git a/packages/vm/viewcontext/viewcontext.go b/packages/vm/viewcontext/viewcontext.go index a5b114ff00..5cdef5886c 100644 --- a/packages/vm/viewcontext/viewcontext.go +++ b/packages/vm/viewcontext/viewcontext.go @@ -120,7 +120,7 @@ func (ctx *ViewContext) GetAccountNFTs(agentID isc.AgentID) []iotago.NFTID { } func (ctx *ViewContext) GetNFTData(nftID iotago.NFTID) *isc.NFT { - return accounts.MustGetNFTData(ctx.contractStateReaderWithGasBurn(accounts.Contract.Hname()), nftID) + return accounts.GetNFTData(ctx.contractStateReaderWithGasBurn(accounts.Contract.Hname()), nftID) } func (ctx *ViewContext) Timestamp() time.Time { diff --git a/packages/vm/vmimpl/internal.go b/packages/vm/vmimpl/internal.go index b7ebf85a1c..c9874ca89d 100644 --- a/packages/vm/vmimpl/internal.go +++ b/packages/vm/vmimpl/internal.go @@ -7,6 +7,7 @@ import ( iotago "github.com/iotaledger/iota.go/v3" "github.com/iotaledger/wasp/packages/isc" "github.com/iotaledger/wasp/packages/kv" + "github.com/iotaledger/wasp/packages/util" "github.com/iotaledger/wasp/packages/util/panicutil" "github.com/iotaledger/wasp/packages/vm" "github.com/iotaledger/wasp/packages/vm/core/accounts" @@ -25,9 +26,18 @@ func creditToAccount(chainState kv.KVStore, agentID isc.AgentID, ftokens *isc.As }) } -func creditNFTToAccount(chainState kv.KVStore, agentID isc.AgentID, nft *isc.NFT) { +func creditNFTToAccount(chainState kv.KVStore, agentID isc.AgentID, req isc.OnLedgerRequest) { + nft := req.NFT() + if nft == nil { + return + } withContractState(chainState, accounts.Contract, func(s kv.KVStore) { - accounts.CreditNFTToAccount(s, agentID, nft) + o := req.Output() + nftOutput := o.(*iotago.NFTOutput) + if nftOutput.NFTID.Empty() { + nftOutput.NFTID = util.NFTIDFromNFTOutput(nftOutput, req.OutputID()) // handle NFTs that were minted diractly to the chain + } + accounts.CreditNFTToAccount(s, agentID, nftOutput) }) } @@ -109,7 +119,7 @@ func (reqctx *requestContext) GetAccountNFTs(agentID isc.AgentID) (ret []iotago. func (reqctx *requestContext) GetNFTData(nftID iotago.NFTID) (ret *isc.NFT) { reqctx.callCore(accounts.Contract, func(s kv.KVStore) { - ret = accounts.MustGetNFTData(s, nftID) + ret = accounts.GetNFTData(s, nftID) }) return ret } @@ -174,6 +184,9 @@ func (vmctx *vmContext) storeUnprocessable(chainState kv.KVStore, unprocessable withContractState(chainState, blocklog.Contract, func(s kv.KVStore) { for _, r := range unprocessable { + if r.SenderAccount() == nil { + continue + } txsnapshot := vmctx.createTxBuilderSnapshot() err := panicutil.CatchPanic(func() { position := vmctx.txbuilder.ConsumeUnprocessable(r) diff --git a/packages/vm/vmimpl/privileged.go b/packages/vm/vmimpl/privileged.go index 702a96c69d..f1c1c9eada 100644 --- a/packages/vm/vmimpl/privileged.go +++ b/packages/vm/vmimpl/privileged.go @@ -50,6 +50,11 @@ func (reqctx *requestContext) ModifyFoundrySupply(sn uint32, delta *big.Int) int return reqctx.vm.txbuilder.ModifyNativeTokenSupply(nativeTokenID, delta) } +func (reqctx *requestContext) MintNFT(addr iotago.Address, immutableMetadata []byte, issuer iotago.Address) (uint16, *iotago.NFTOutput) { + reqctx.mustBeCalledFromContract(accounts.Contract) + return reqctx.vm.txbuilder.MintNFT(addr, immutableMetadata, issuer) +} + func (reqctx *requestContext) RetryUnprocessable(req isc.Request, outputID iotago.OutputID) { retryReq := isc.NewRetryOnLedgerRequest(req.(isc.OnLedgerRequest), outputID) reqctx.unprocessableToRetry = append(reqctx.unprocessableToRetry, retryReq) diff --git a/packages/vm/vmimpl/runreq.go b/packages/vm/vmimpl/runreq.go index b5015c3690..8407ce5468 100644 --- a/packages/vm/vmimpl/runreq.go +++ b/packages/vm/vmimpl/runreq.go @@ -89,27 +89,25 @@ func (vmctx *vmContext) payoutAgentID() isc.AgentID { // creditAssetsToChain credits L1 accounts with attached assets and accrues all of them to the sender's account on-chain func (reqctx *requestContext) creditAssetsToChain() { - req := reqctx.req - if req.IsOffLedger() { + req, ok := reqctx.req.(isc.OnLedgerRequest) + if !ok { // off ledger request does not bring any deposit return } // Consume the output. Adjustment in L2 is needed because of storage deposit in the internal UTXOs - storageDepositNeeded := reqctx.vm.txbuilder.Consume(req.(isc.OnLedgerRequest)) + storageDepositNeeded := reqctx.vm.txbuilder.Consume(req) // if sender is specified, all assets goes to sender's sender - // Otherwise it all goes to the common sender and panics is logged in the SC call + // Otherwise it all goes to the common sender and panic is logged in the SC call sender := req.SenderAccount() if sender == nil { - if req.IsOffLedger() { - panic("nil sender on offledger requests should never happen") + if storageDepositNeeded > req.Assets().BaseTokens { + panic(vmexceptions.ErrNotEnoughFundsForSD) // if sender is not specified, and extra tokens are needed to pay for SD, the request cannot be processed. } // onleger request with no sender, send all assets to the payoutAddress payoutAgentID := reqctx.vm.payoutAgentID() - creditNFTToAccount(reqctx.uncommittedState, payoutAgentID, req.NFT()) + creditNFTToAccount(reqctx.uncommittedState, payoutAgentID, req) creditToAccount(reqctx.uncommittedState, payoutAgentID, req.Assets()) - - // debit any SD required for accounting UTXOs if storageDepositNeeded > 0 { debitFromAccount(reqctx.uncommittedState, payoutAgentID, isc.NewAssetsBaseTokens(storageDepositNeeded)) } @@ -118,13 +116,14 @@ func (reqctx *requestContext) creditAssetsToChain() { senderBaseTokens := req.Assets().BaseTokens + reqctx.GetBaseTokensBalance(sender) - if senderBaseTokens < storageDepositNeeded { + minReqCost := reqctx.ChainInfo().GasFeePolicy.MinFee() + if senderBaseTokens < storageDepositNeeded+minReqCost { // user doesn't have enough funds to pay for the SD needs of this request panic(vmexceptions.ErrNotEnoughFundsForSD) } creditToAccount(reqctx.uncommittedState, sender, req.Assets()) - creditNFTToAccount(reqctx.uncommittedState, sender, req.NFT()) + creditNFTToAccount(reqctx.uncommittedState, sender, req) if storageDepositNeeded > 0 { reqctx.sdCharged = storageDepositNeeded debitFromAccount(reqctx.uncommittedState, sender, isc.NewAssetsBaseTokens(storageDepositNeeded)) diff --git a/packages/vm/vmimpl/runtask.go b/packages/vm/vmimpl/runtask.go index bd739cd16a..03a8ffe6c6 100644 --- a/packages/vm/vmimpl/runtask.go +++ b/packages/vm/vmimpl/runtask.go @@ -125,7 +125,7 @@ func (vmctx *vmContext) init(prevL1Commitment *state.L1Commitment) { // save the OutputID of the newly created tokens, foundries and NFTs in the previous block vmctx.withStateUpdate(func(chainState kv.KVStore) { withContractState(chainState, accounts.Contract, func(s kv.KVStore) { - accounts.UpdateLatestOutputID(s, vmctx.task.AnchorOutputID.TransactionID()) + accounts.UpdateLatestOutputID(s, vmctx.task.AnchorOutputID.TransactionID(), vmctx.task.AnchorOutput.StateIndex) }) }) diff --git a/packages/vm/vmimpl/sandbox.go b/packages/vm/vmimpl/sandbox.go index a8e06252ab..c14798baef 100644 --- a/packages/vm/vmimpl/sandbox.go +++ b/packages/vm/vmimpl/sandbox.go @@ -145,6 +145,10 @@ func (s *contractSandbox) ModifyFoundrySupply(sn uint32, delta *big.Int) int64 { return s.reqctx.ModifyFoundrySupply(sn, delta) } +func (s *contractSandbox) MintNFT(addr iotago.Address, immutableMetadata []byte, issuer iotago.Address) (uint16, *iotago.NFTOutput) { + return s.reqctx.MintNFT(addr, immutableMetadata, issuer) +} + func (s *contractSandbox) GasBurnEnable(enable bool) { s.Ctx.GasBurnEnable(enable) } diff --git a/packages/vm/vmimpl/send.go b/packages/vm/vmimpl/send.go index 0fbf440798..8f71355432 100644 --- a/packages/vm/vmimpl/send.go +++ b/packages/vm/vmimpl/send.go @@ -14,7 +14,7 @@ const MaxPostedOutputsInOneRequest = 4 func (vmctx *vmContext) getNFTData(chainState kv.KVStore, nftID iotago.NFTID) *isc.NFT { var nft *isc.NFT withContractState(chainState, accounts.Contract, func(s kv.KVStore) { - nft = accounts.MustGetNFTData(s, nftID) + nft = accounts.GetNFTData(s, nftID) }) return nft } diff --git a/packages/vm/vmimpl/vmcontext.go b/packages/vm/vmimpl/vmcontext.go index 46c5bf21e1..e61f111028 100644 --- a/packages/vm/vmimpl/vmcontext.go +++ b/packages/vm/vmimpl/vmcontext.go @@ -193,16 +193,15 @@ func (vmctx *vmContext) saveInternalUTXOs(unprocessable []isc.OnLedgerRequest) { // IMPORTANT: do not iterate by this map, order of the slice above must be respected foundryOutputsMap := vmctx.txbuilder.FoundryOutputsBySN(foundryIDsToBeUpdated) - NFTOutputsToBeAdded, NFTOutputsToBeRemoved := vmctx.txbuilder.NFTOutputsToBeUpdated() + NFTOutputsToBeAdded, NFTOutputsToBeRemoved, MintedNFTOutputs := vmctx.txbuilder.NFTOutputsToBeUpdated() - blockIndex := vmctx.task.AnchorOutput.StateIndex + 1 outputIndex := uint16(1) withContractState(vmctx.stateDraft, accounts.Contract, func(s kv.KVStore) { // update native token outputs for _, ntID := range nativeTokenIDsToBeUpdated { vmctx.task.Log.Debugf("saving NT %s, outputIndex: %d", ntID, outputIndex) - accounts.SaveNativeTokenOutput(s, nativeTokensMap[ntID], blockIndex, outputIndex) + accounts.SaveNativeTokenOutput(s, nativeTokensMap[ntID], outputIndex) outputIndex++ } for _, id := range nativeTokensToBeRemoved { @@ -213,7 +212,7 @@ func (vmctx *vmContext) saveInternalUTXOs(unprocessable []isc.OnLedgerRequest) { // update foundry UTXOs for _, foundryID := range foundryIDsToBeUpdated { vmctx.task.Log.Debugf("saving foundry %d, outputIndex: %d", foundryID, outputIndex) - accounts.SaveFoundryOutput(s, foundryOutputsMap[foundryID], blockIndex, outputIndex) + accounts.SaveFoundryOutput(s, foundryOutputsMap[foundryID], outputIndex) outputIndex++ } for _, sn := range foundriesToBeRemoved { @@ -224,13 +223,19 @@ func (vmctx *vmContext) saveInternalUTXOs(unprocessable []isc.OnLedgerRequest) { // update NFT Outputs for _, out := range NFTOutputsToBeAdded { vmctx.task.Log.Debugf("saving NFT %s, outputIndex: %d", out.NFTID, outputIndex) - accounts.SaveNFTOutput(s, out, blockIndex, outputIndex) + accounts.SaveNFTOutput(s, out, outputIndex) outputIndex++ } for _, out := range NFTOutputsToBeRemoved { vmctx.task.Log.Debugf("deleting NFT %s", out.NFTID) accounts.DeleteNFTOutput(s, out.NFTID) } + + for positionInMintedList := range MintedNFTOutputs { + vmctx.task.Log.Debugf("minted NFT on output index: %d", outputIndex) + accounts.SaveMintedNFTOutput(s, uint16(positionInMintedList), outputIndex) + outputIndex++ + } }) // add unprocessable requests diff --git a/packages/vm/vmtxbuilder/nfts.go b/packages/vm/vmtxbuilder/nfts.go index 579fbd25c4..01ac16a35b 100644 --- a/packages/vm/vmtxbuilder/nfts.go +++ b/packages/vm/vmtxbuilder/nfts.go @@ -2,7 +2,8 @@ package vmtxbuilder import ( "bytes" - "sort" + + "golang.org/x/exp/slices" iotago "github.com/iotaledger/iota.go/v3" "github.com/iotaledger/wasp/packages/parameters" @@ -50,8 +51,8 @@ func (txb *AnchorTransactionBuilder) nftsSorted() []*nftIncluded { for _, nft := range txb.nftsIncluded { ret = append(ret, nft) } - sort.Slice(ret, func(i, j int) bool { - return bytes.Compare(ret[i].ID[:], ret[j].ID[:]) == -1 + slices.SortFunc(ret, func(a, b *nftIncluded) int { + return bytes.Compare(a.ID[:], b.ID[:]) }) return ret } @@ -67,29 +68,29 @@ func (txb *AnchorTransactionBuilder) NFTOutputs() []*iotago.NFTOutput { return outs } -func (txb *AnchorTransactionBuilder) NFTOutputsToBeUpdated() (toBeAdded, toBeRemoved []*iotago.NFTOutput) { +func (txb *AnchorTransactionBuilder) NFTOutputsToBeUpdated() (toBeAdded, toBeRemoved []*iotago.NFTOutput, minted []iotago.Output) { toBeAdded = make([]*iotago.NFTOutput, 0, len(txb.nftsIncluded)) toBeRemoved = make([]*iotago.NFTOutput, 0, len(txb.nftsIncluded)) for _, nft := range txb.nftsSorted() { - if nft.accountingInput != nil { - // to remove if input is not nil (nft exists in accounting), and its sent to outside the chain + if nft.accountingInput != nil && nft.sentOutside { + // to remove if input is not nil (nft exists in accounting), and it's sent to outside the chain toBeRemoved = append(toBeRemoved, nft.resultingOutput) continue } if nft.sentOutside { - // do nothing if input is nil (doesn't exist in accounting) and its sent outside (comes in and leaves on the same block) + // do nothing if input is nil (doesn't exist in accounting) and it's sent outside (comes in and leaves on the same block) continue } - // to add if input is nil (doesn't exist in accounting), and its not sent outside the chain + // to add if input is nil (doesn't exist in accounting), and it's not sent outside the chain toBeAdded = append(toBeAdded, nft.resultingOutput) } - return toBeAdded, toBeRemoved + return toBeAdded, toBeRemoved, txb.nftsMinted } func (txb *AnchorTransactionBuilder) internalNFTOutputFromRequest(nftOutput *iotago.NFTOutput, outputID iotago.OutputID) *nftIncluded { out := nftOutput.Clone().(*iotago.NFTOutput) out.Amount = 0 - chainAddr := txb.anchorOutput.AliasID.ToAddress() + chainAddr := txb.chainAddress() out.NativeTokens = nil out.Conditions = iotago.UnlockConditions{ &iotago.AddressUnlockCondition{ @@ -151,3 +152,59 @@ func (txb *AnchorTransactionBuilder) sendNFT(o *iotago.NFTOutput) int64 { return int64(in.Deposit()) } + +func (txb *AnchorTransactionBuilder) MintNFT(addr iotago.Address, immutableMetadata []byte, issuer iotago.Address) (uint16, *iotago.NFTOutput) { + chainAddr := txb.chainAddress() + if !issuer.Equal(chainAddr) { + // include collection issuer NFT output in the txbuilder + nftAddr, ok := issuer.(*iotago.NFTAddress) + if !ok { + panic("issuer must be an NFTID or the chain itself") + } + nftID := nftAddr.NFTID() + if txb.nftsIncluded[nftID] == nil { + if txb.InputsAreFull() { + panic(vmexceptions.ErrInputLimitExceeded) + } + if txb.outputsAreFull() { + panic(vmexceptions.ErrOutputLimitExceeded) + } + o, oID := txb.accountsView.NFTOutput(nftAddr.NFTID()) + clonedOutput := o.Clone() + resultingOutput := clonedOutput.(*iotago.NFTOutput) + if o.NFTID.Empty() { + resultingOutput.NFTID = nftID + } + txb.nftsIncluded[nftID] = &nftIncluded{ + ID: nftID, + accountingInputID: oID, + accountingInput: o, + resultingOutput: resultingOutput, + sentOutside: false, + } + } + } + + if txb.outputsAreFull() { + panic(vmexceptions.ErrOutputLimitExceeded) + } + + nftOutput := &iotago.NFTOutput{ + NFTID: iotago.NFTID{}, + Conditions: iotago.UnlockConditions{ + &iotago.AddressUnlockCondition{Address: addr}, + }, + Features: iotago.Features{ + &iotago.SenderFeature{ + Address: chainAddr, // must set the chainID as the sender (so its recognized as an internalUTXO) + }, + }, + ImmutableFeatures: iotago.Features{ + &iotago.IssuerFeature{Address: issuer}, + &iotago.MetadataFeature{Data: immutableMetadata}, + }, + } + nftOutput.Amount = parameters.L1().Protocol.RentStructure.MinRent(nftOutput) + txb.nftsMinted = append(txb.nftsMinted, nftOutput) + return uint16(len(txb.nftsMinted) - 1), nftOutput +} diff --git a/packages/vm/vmtxbuilder/totals.go b/packages/vm/vmtxbuilder/totals.go index d23843c38e..b80f310e9b 100644 --- a/packages/vm/vmtxbuilder/totals.go +++ b/packages/vm/vmtxbuilder/totals.go @@ -134,6 +134,9 @@ func (txb *AnchorTransactionBuilder) sumOutputs() *TransactionTotals { totals.TotalBaseTokensInStorageDeposit += nft.resultingOutput.Amount } } + for _, nft := range txb.nftsMinted { + totals.SentOutBaseTokens += nft.Deposit() + } return totals } diff --git a/packages/vm/vmtxbuilder/txbuilder.go b/packages/vm/vmtxbuilder/txbuilder.go index bd9c8acfac..4fd02f4904 100644 --- a/packages/vm/vmtxbuilder/txbuilder.go +++ b/packages/vm/vmtxbuilder/txbuilder.go @@ -51,6 +51,8 @@ type AnchorTransactionBuilder struct { balanceNativeTokens map[iotago.NativeTokenID]*nativeTokenBalance // all nfts loaded during the batch run nftsIncluded map[iotago.NFTID]*nftIncluded + // all nfts minted + nftsMinted []iotago.Output // invoked foundries. Foundry serial number is used as a key invokedFoundries map[uint32]*foundryInvoked // requests posted by smart contracts @@ -74,6 +76,7 @@ func NewAnchorTransactionBuilder( postedOutputs: make([]iotago.Output, 0, iotago.MaxOutputsCount-1), invokedFoundries: make(map[uint32]*foundryInvoked), nftsIncluded: make(map[iotago.NFTID]*nftIncluded), + nftsMinted: make([]iotago.Output, 0), } } @@ -92,6 +95,7 @@ func (txb *AnchorTransactionBuilder) Clone() *AnchorTransactionBuilder { postedOutputs: util.CloneSlice(txb.postedOutputs), invokedFoundries: util.CloneMap(txb.invokedFoundries), nftsIncluded: util.CloneMap(txb.nftsIncluded), + nftsMinted: util.CloneSlice(txb.nftsMinted), } } @@ -299,7 +303,9 @@ func (txb *AnchorTransactionBuilder) CreateAnchorOutput(stateMetadata []byte) *i // 0. Anchor Output // 1. NativeTokens // 2. Foundries -// 3. NFTs +// 3. received NFTs +// 4. minted NFTs +// 5. other outputs (posted from requests) func (txb *AnchorTransactionBuilder) outputs(stateMetadata []byte) iotago.Outputs { ret := make(iotago.Outputs, 0, 1+len(txb.balanceNativeTokens)+len(txb.postedOutputs)) @@ -317,11 +323,14 @@ func (txb *AnchorTransactionBuilder) outputs(stateMetadata []byte) iotago.Output for _, sn := range foundriesToBeUpdated { ret = append(ret, txb.invokedFoundries[sn].accountingOutput) } - // creating outputs for new NFTs + // creating outputs for received NFTs nftOuts := txb.NFTOutputs() for _, nftOut := range nftOuts { ret = append(ret, nftOut) } + // creating outputs for minted NFTs + ret = append(ret, txb.nftsMinted...) + // creating outputs for posted on-ledger requests ret = append(ret, txb.postedOutputs...) return ret @@ -362,6 +371,7 @@ func (txb *AnchorTransactionBuilder) numOutputs() int { ret++ } } + ret += len(txb.nftsMinted) return ret } @@ -417,3 +427,7 @@ func retryOutputFromOnLedgerRequest(req isc.OnLedgerRequest, chainAliasID iotago } return out } + +func (txb *AnchorTransactionBuilder) chainAddress() iotago.Address { + return txb.anchorOutput.AliasID.ToAddress() +}