Repository: algorand/indexer
Pull request #302 (2021-02-04 19:01:08)

DefaultFrozen Asset Accounting Fix
## Summary

Fix the asset default frozen cache so that asset holdings are created correctly.
Fix accounting issues related to re-opened asset holdings.

## Test Plan

New unit and integration tests.


Patch:
diff --git a/accounting/accounting.go b/accounting/accounting.go
index f27c10a..e87fd5d 100644
--- a/accounting/accounting.go
+++ b/accounting/accounting.go
@@ -34,8 +34,8 @@ type State struct {
 }
 
 // New creates a new State object.
-func New() *State {
-	result := &State{defaultFrozen: make(map[uint64]bool)}
+func New(defaultFrozenCache map[uint64]bool) *State {
+	result := &State{defaultFrozen: defaultFrozenCache}
 	result.Clear()
 	return result
 }
@@ -148,71 +148,92 @@ func (accounting *State) updateAccountData(addr types.Address, key string, field
 }
 
 func (accounting *State) updateAsset(addr types.Address, assetID uint64, add, sub uint64) {
+	// in-place optimization in case an asset is modified by multiple transactions.
 	// Get update list from final subround. When a subround ends an empty subround is appended
 	// so there is no need to check whether an account has closed.
 	updatelist := accounting.AssetUpdates[len(accounting.AssetUpdates)-1][addr]
 	for i, up := range updatelist {
-		if up.AssetID == assetID {
+		if up.Transfer != nil && up.AssetID == assetID {
 			if add != 0 {
 				var xa big.Int
 				xa.SetUint64(add)
-				up.Delta.Add(&xa, &up.Delta)
+				up.Transfer.Delta.Add(&xa, &up.Transfer.Delta)
 			}
 			if sub != 0 {
 				var xa big.Int
 				xa.SetUint64(sub)
-				up.Delta.Sub(&up.Delta, &xa)
+				up.Transfer.Delta.Sub(&up.Transfer.Delta, &xa)
 			}
 			updatelist[i] = up
 			return
 		}
 	}
 
-	au := idb.AssetUpdate{AssetID: assetID, DefaultFrozen: accounting.defaultFrozen[assetID]}
+	au := idb.AssetUpdate{AssetID: assetID, DefaultFrozen: accounting.defaultFrozen[assetID], Transfer: &idb.AssetTransfer{}}
 	if add != 0 {
 		var xa big.Int
 		xa.SetUint64(add)
-		au.Delta.Add(&au.Delta, &xa)
+		au.Transfer.Delta.Add(&au.Transfer.Delta, &xa)
 	}
 	if sub != 0 {
 		var xa big.Int
 		xa.SetUint64(sub)
-		au.Delta.Sub(&au.Delta, &xa)
+		au.Transfer.Delta.Sub(&au.Transfer.Delta, &xa)
 	}
 
-	// Add the AssetUpdate to the final subround
-	accounting.AssetUpdates[len(accounting.AssetUpdates)-1][addr] = append(updatelist, au)
+	accounting.addAssetAccounting(addr, au, false)
 }
 
 func (accounting *State) updateTxnAsset(round uint64, intra int, assetID uint64) {
 	accounting.TxnAssetUpdates = append(accounting.TxnAssetUpdates, idb.TxnAssetUpdate{Round: round, Offset: intra, AssetID: assetID})
 }
 
-func (accounting *State) closeAsset(from types.Address, assetID uint64, to types.Address, round uint64, offset int) {
-	updatelist := accounting.AssetUpdates[len(accounting.AssetUpdates)-1][from]
+func (accounting *State) addAssetAccounting(addr types.Address, update idb.AssetUpdate, finalizeSubround bool) {
+	// Add the final subround update
+	updatelist := accounting.AssetUpdates[len(accounting.AssetUpdates)-1][addr]
+	accounting.AssetUpdates[len(accounting.AssetUpdates)-1][addr] = append(updatelist, update)
+
+	// Put an empty subround for any subsequent updates.
+	if finalizeSubround {
+		accounting.AssetUpdates = append(accounting.AssetUpdates, make(map[[32]byte][]idb.AssetUpdate))
+	}
+}
+
+func (accounting *State) configAsset(assetID uint64, isNew bool, creator types.Address, params atypes.AssetParams){
+	update := idb.AssetUpdate{
+		AssetID: assetID,
+		DefaultFrozen: accounting.defaultFrozen[assetID],
+		Config: &idb.AcfgUpdate{
+			IsNew:   isNew,
+			Creator: creator,
+			Params:  params,
+		},
+	}
+	// This probably doesn't need to finalize the subround, but it is an uncommon transaction so lets play it safe.
+	accounting.addAssetAccounting(creator, update, true)
+}
 
-	assetClose := &idb.AssetClose{
+func (accounting *State) closeAsset(from types.Address, assetID uint64, to types.Address, round uint64, offset int) {
+	update := idb.AssetUpdate{
+		AssetID: assetID,
+		DefaultFrozen: accounting.defaultFrozen[assetID],
+		Close: &idb.AssetClose{
 			CloseTo:       to,
-			AssetID:       assetID,
 			Sender:        from,
-			DefaultFrozen: accounting.defaultFrozen[assetID],
 			Round:         round,
 			Offset:        uint64(offset),
+		},
 	}
-
-	// Add an empty AssetUpdate with asset close reference.
-	accounting.AssetUpdates[len(accounting.AssetUpdates)-1][from] = append(updatelist, idb.AssetUpdate{
-		AssetID:       assetID,
-		Delta:         *big.NewInt(0),
-		DefaultFrozen: assetClose.DefaultFrozen,
-		Closed:        assetClose,
-	})
-
-	// Put an empty subround for any subsequent updates.
-	accounting.AssetUpdates = append(accounting.AssetUpdates, make(map[[32]byte][]idb.AssetUpdate))
+	accounting.addAssetAccounting(from, update, true)
 }
+
 func (accounting *State) freezeAsset(addr types.Address, assetID uint64, frozen bool) {
-	accounting.FreezeUpdates = append(accounting.FreezeUpdates, idb.FreezeUpdate{Addr: addr, AssetID: assetID, Frozen: frozen})
+	update := idb.AssetUpdate{
+		AssetID: assetID,
+		DefaultFrozen: accounting.defaultFrozen[assetID],
+		Freeze: &idb.FreezeUpdate{Frozen: frozen},
+	}
+	accounting.addAssetAccounting(addr, update, false)
 }
 
 func (accounting *State) destroyAsset(assetID uint64) {
@@ -336,8 +357,11 @@ func (accounting *State) AddTransaction(txnr *idb.TxnRow) (err error) {
 		if AssetDestroyTxn(stxn) {
 			accounting.destroyAsset(assetID)
 		} else {
-			accounting.AcfgUpdates = append(accounting.AcfgUpdates, idb.AcfgUpdate{AssetID: assetID, IsNew: isNew, Creator: stxn.Txn.Sender, Params: stxn.Txn.AssetParams})
-			accounting.defaultFrozen[assetID] = stxn.Txn.AssetParams.DefaultFrozen
+			accounting.configAsset(assetID, isNew, stxn.Txn.Sender, stxn.Txn.AssetParams)
+			// Only update the cache when default-frozen = true.
+			if stxn.Txn.AssetParams.DefaultFrozen {
+				accounting.defaultFrozen[assetID] = stxn.Txn.AssetParams.DefaultFrozen
+			}
 			if stxn.Txn.ConfigAsset == 0 {
 				// initial creation, give all initial value to creator
 				if stxn.Txn.AssetParams.Total != 0 {
diff --git a/cmd/algorand-indexer/daemon.go b/cmd/algorand-indexer/daemon.go
index 5e4d02f..9935025 100644
--- a/cmd/algorand-indexer/daemon.go
+++ b/cmd/algorand-indexer/daemon.go
@@ -84,9 +84,12 @@ var daemonCmd = &cobra.Command{
 			if maxRound != 0 {
 				bot.SetNextRound(maxRound + 1)
 			}
+			cache, err := db.GetDefaultFrozen()
+			maybeFail(err, "failed to get default frozen cache")
 			bih := blockImporterHandler{
 				imp:   importer.NewDBImporter(db),
 				db:    db,
+				cache: cache,
 				round: maxRound,
 			}
 			bot.AddBlockHandler(&bih)
@@ -161,6 +164,7 @@ func init() {
 type blockImporterHandler struct {
 	imp   importer.Importer
 	db    idb.IndexerDb
+	cache map[uint64]bool
 	round uint64
 }
 
@@ -171,7 +175,7 @@ func (bih *blockImporterHandler) HandleBlock(block *types.EncodedBlockCert) {
 	}
 	_, err := bih.imp.ImportDecodedBlock(block)
 	maybeFail(err, "ImportDecodedBlock %d: %v", block.Block.Round, err)
-	importer.UpdateAccounting(bih.db, uint64(block.Block.Round), genesisJSONPath, logger)
+	importer.UpdateAccounting(bih.db, bih.cache, uint64(block.Block.Round), genesisJSONPath, logger)
 	dt := time.Now().Sub(start)
 	logger.Infof("round r=%d (%d txn) imported in %s", block.Block.Round, len(block.Block.Payset), dt.String())
 	bih.round = uint64(block.Block.Round)
diff --git a/cmd/algorand-indexer/import.go b/cmd/algorand-indexer/import.go
index 276564d..d9776d5 100644
--- a/cmd/algorand-indexer/import.go
+++ b/cmd/algorand-indexer/import.go
@@ -24,7 +24,14 @@ var importCmd = &cobra.Command{
 
 		db := globalIndexerDb(nil)
 
+		cache, err := db.GetDefaultFrozen()
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "failed to initialize the default frozen cache: %v", err)
+			os.Exit(1)
+		}
+
 		helper := importer.NewImportHelper(
+			cache,
 			genesisJSONPath,
 			numRoundsLimit,
 			blockFileLimit,
diff --git a/idb/dummy.go b/idb/dummy.go
index 39fb7dd..c3c857b 100644
--- a/idb/dummy.go
+++ b/idb/dummy.go
@@ -91,6 +91,11 @@ func (db *dummyIndexerDb) GetSpecialAccounts() (SpecialAccounts, error) {
 	return SpecialAccounts{}, nil
 }
 
+// GetDefaultFrozen is part of idb.IndexerDb
+func (db *dummyIndexerDb) GetDefaultFrozen() (defaultFrozen map[uint64]bool, err error) {
+	return make(map[uint64]bool), nil
+}
+
 // YieldTxns is part of idb.IndexerDB
 func (db *dummyIndexerDb) YieldTxns(ctx context.Context, prevRound int64) <-chan TxnRow {
 	return nil
@@ -214,6 +219,7 @@ type IndexerDb interface {
 	SetMetastate(key, jsonStrValue string) (err error)
 	GetMaxRound() (round uint64, err error)
 	GetSpecialAccounts() (SpecialAccounts, error)
+	GetDefaultFrozen() (defaultFrozen map[uint64]bool, err error)
 
 	// YieldTxns returns a channel that produces the whole transaction stream after some round forward
 	YieldTxns(ctx context.Context, prevRound int64) <-chan TxnRow
@@ -439,37 +445,39 @@ type AccountDataUpdate struct {
 	VoteKeyDilution uint64
 }
 
+// AssetUpdate is used by the accounting and IndexerDb implementations to share modifications in a block.
+type AssetUpdate struct {
+	AssetID       uint64
+	DefaultFrozen bool
+	Transfer *AssetTransfer
+	Close    *AssetClose
+	Config   *AcfgUpdate
+	Freeze   *FreezeUpdate
+}
+
 // AcfgUpdate is used by the accounting and IndexerDb implementations to share modifications in a block.
 type AcfgUpdate struct {
-	AssetID uint64
 	IsNew   bool
 	Creator types.Address
 	Params  types.AssetParams
 }
 
-// AssetUpdate is used by the accounting and IndexerDb implementations to share modifications in a block.
-type AssetUpdate struct {
-	AssetID       uint64
+// AssetTransfer is used by the accounting and IndexerDb implementations to share modifications in a block.
+type AssetTransfer struct {
 	Delta         big.Int
-	DefaultFrozen bool
-	Closed        *AssetClose
 }
 
 // FreezeUpdate is used by the accounting and IndexerDb implementations to share modifications in a block.
 type FreezeUpdate struct {
-	Addr    types.Address
-	AssetID uint64
 	Frozen  bool
 }
 
 // AssetClose is used by the accounting and IndexerDb implementations to share modifications in a block.
 type AssetClose struct {
 	CloseTo       types.Address
-	AssetID       uint64
 	Sender        types.Address
 	Round         uint64
 	Offset        uint64
-	DefaultFrozen bool
 }
 
 // TxnAssetUpdate is used by the accounting and IndexerDb implementations to share modifications in a block.
@@ -506,7 +514,6 @@ type RoundUpdates struct {
 	// with a 0 value.
 	AccountDataUpdates map[[32]byte]map[string]interface{}
 
-	AcfgUpdates     []AcfgUpdate
 	TxnAssetUpdates []TxnAssetUpdate
 
 	// AssetUpdates is more complicated than AlgoUpdates because there
@@ -520,8 +527,8 @@ type RoundUpdates struct {
 	// The next subround starts when an account close has been detected
 	// Once a subround has been processed, move to the next subround and
 	// apply the updates.
+	// AssetConfig transactions also trigger the end of a subround.
 	AssetUpdates  []map[[32]byte][]AssetUpdate
-	FreezeUpdates []FreezeUpdate
 	AssetDestroys []uint64
 
 	AppGlobalDeltas []AppDelta
@@ -533,11 +540,9 @@ func (ru *RoundUpdates) Clear() {
 	ru.AlgoUpdates = nil
 	ru.AccountTypes = nil
 	ru.AccountDataUpdates = nil
-	ru.AcfgUpdates = nil
 	ru.TxnAssetUpdates = nil
 	ru.AssetUpdates = nil
 	ru.AssetUpdates = append(ru.AssetUpdates, make(map[[32]byte][]AssetUpdate, 0))
-	ru.FreezeUpdates = nil
 	ru.AssetDestroys = nil
 	ru.AppGlobalDeltas = nil
 	ru.AppLocalDeltas = nil
diff --git a/idb/mocks/IndexerDb.go b/idb/mocks/IndexerDb.go
index 0f47b7f..5099dd4 100644
--- a/idb/mocks/IndexerDb.go
+++ b/idb/mocks/IndexerDb.go
@@ -166,6 +166,29 @@ func (_m *IndexerDb) GetBlock(round uint64) (types.Block, error) {
 	return r0, r1
 }
 
+// GetDefaultFrozen provides a mock function with given fields:
+func (_m *IndexerDb) GetDefaultFrozen() (map[uint64]bool, error) {
+	ret := _m.Called()
+
+	var r0 map[uint64]bool
+	if rf, ok := ret.Get(0).(func() map[uint64]bool); ok {
+		r0 = rf()
+	} else {
+		if ret.Get(0) != nil {
+			r0 = ret.Get(0).(map[uint64]bool)
+		}
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func() error); ok {
+		r1 = rf()
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
 // GetMaxRound provides a mock function with given fields:
 func (_m *IndexerDb) GetMaxRound() (uint64, error) {
 	ret := _m.Called()
diff --git a/idb/postgres/postgres.go b/idb/postgres/postgres.go
index ecd5efc..4d2cc9f 100644
--- a/idb/postgres/postgres.go
+++ b/idb/postgres/postgres.go
@@ -121,6 +121,7 @@ func (db *IndexerDb) init() (err error) {
 	if err != nil {
 		return
 	}
+
 	err = db.markMigrationsAsDone()
 	return
 }
@@ -288,21 +289,21 @@ func (db *IndexerDb) GetAsset(assetid uint64) (asset types.AssetParams, err erro
 	return
 }
 
-// GetDefaultFrozen get {assetid:default frozen, ...} for all assets
+// GetDefaultFrozen get {assetid:default frozen, ...} for all assets, needed by accounting.
+// Because Go map[]bool returns false by default, we actually return only a map of the true elements.
 func (db *IndexerDb) GetDefaultFrozen() (defaultFrozen map[uint64]bool, err error) {
-	rows, err := db.db.Query(`SELECT index, params -> 'df' FROM asset a`)
+	rows, err := db.db.Query(`SELECT index FROM asset WHERE (params ->> 'df')::boolean = true`)
 	if err != nil {
 		return
 	}
 	defaultFrozen = make(map[uint64]bool)
 	for rows.Next() {
 		var assetid uint64
-		var frozen bool
-		err = rows.Scan(&assetid, &frozen)
+		err = rows.Scan(&assetid)
 		if err != nil {
 			return
 		}
-		defaultFrozen[assetid] = frozen
+		defaultFrozen[assetid] = true
 	}
 	return
 }
@@ -827,46 +828,6 @@ func (db *IndexerDb) CommitRoundAccounting(updates idb.RoundUpdates, round uint6
 			}
 		}
 	}
-	if len(updates.AcfgUpdates) > 0 {
-		any = true
-		setacfg, err := tx.Prepare(`INSERT INTO asset (index, creator_addr, params, created_at, deleted) VALUES ($1, $2, $3, $4, false) ON CONFLICT (index) DO UPDATE SET params = EXCLUDED.params, deleted = false`)
-		if err != nil {
-			return fmt.Errorf("prepare set asset, %v", err)
-		}
-		defer setacfg.Close()
-		getacfg, err := tx.Prepare(`SELECT params FROM asset WHERE index = $1`)
-		if err != nil {
-			return fmt.Errorf("prepare get asset, %v", err)
-		}
-		defer getacfg.Close()
-		for _, au := range updates.AcfgUpdates {
-			if au.AssetID == debugAsset {
-				db.log.Errorf("%d acfg %s %s", round, b64(au.Creator[:]), obs(au))
-			}
-			var outparams string
-			if au.IsNew {
-				outparams = idb.JSONOneLine(au.Params)
-			} else {
-				row := getacfg.QueryRow(au.AssetID)
-				var paramjson []byte
-				err = row.Scan(&paramjson)
-				if err != nil {
-					return fmt.Errorf("get acfg %d, %v", au.AssetID, err)
-				}
-				var old atypes.AssetParams
-				err = json.Decode(paramjson, &old)
-				if err != nil {
-					return fmt.Errorf("bad acgf json %d, %v", au.AssetID, err)
-				}
-				np := types.MergeAssetConfig(old, au.Params)
-				outparams = idb.JSONOneLine(np)
-			}
-			_, err = setacfg.Exec(au.AssetID, au.Creator[:], outparams, round)
-			if err != nil {
-				return fmt.Errorf("update asset, %v", err)
-			}
-		}
-	}
 	if len(updates.TxnAssetUpdates) > 0 {
 		any = true
 		uta, err := tx.Prepare(`UPDATE txn SET asset = $1 WHERE round = $2 AND intra = $3`)
@@ -883,13 +844,22 @@ func (db *IndexerDb) CommitRoundAccounting(updates idb.RoundUpdates, round uint6
 	}
 	if len(updates.AssetUpdates) > 0 && len(updates.AssetUpdates[0]) > 0 {
 		any = true
-		// Create new account_asset, or initialize a previously destroyed asset.
-		seta, err := tx.Prepare(`INSERT INTO account_asset (addr, assetid, amount, frozen, created_at, deleted) VALUES ($1, $2, $3, $4, $5, false) ON CONFLICT (addr, assetid) DO UPDATE SET amount = account_asset.amount + EXCLUDED.amount, deleted = false`)
+
+		////////////////
+		// Asset Xfer //
+		////////////////
+		// Create new account_asset, initialize a previously destroyed asset, or apply the balance delta.
+		// Setting frozen is complicated for the no-op optin case. It should only be set to default-frozen when the
+		// holding is deleted, otherwise it should be left as the original value.
+		seta, err := tx.Prepare(`INSERT INTO account_asset (addr, assetid, amount, frozen, created_at, deleted) VALUES ($1, $2, $3, $4, $5, false) ON CONFLICT (addr, assetid) DO UPDATE SET amount = account_asset.amount + EXCLUDED.amount, frozen = (EXCLUDED.frozen AND account_asset.deleted) OR (account_asset.frozen AND NOT account_asset.deleted), deleted = false`)
 		if err != nil {
 			return fmt.Errorf("prepare set account_asset, %v", err)
 		}
 		defer seta.Close()
 
+		/////////////////
+		// Asset Close //
+		/////////////////
 		// On asset opt-out attach some extra "apply data" metadata to allow rewinding the asset close if requested.
 		acc, err := tx.Prepare(`WITH aaamount AS (SELECT ($1)::bigint as round, ($2)::bigint as intra, x.amount FROM account_asset x WHERE x.addr = $3 AND x.assetid = $4)
 UPDATE txn ut SET extra = jsonb_set(coalesce(ut.extra, '{}'::jsonb), '{aca}', to_jsonb(aaamount.amount)) FROM aaamount WHERE ut.round = aaamount.round AND ut.intra = aaamount.intra`)
@@ -912,6 +882,29 @@ ON CONFLICT (addr, assetid) DO UPDATE SET amount = account_asset.amount + EXCLUD
 		}
 		defer acd.Close()
 
+		//////////////////
+		// Asset Config //
+		//////////////////
+		setacfg, err := tx.Prepare(`INSERT INTO asset (index, creator_addr, params, created_at, deleted) VALUES ($1, $2, $3, $4, false) ON CONFLICT (index) DO UPDATE SET params = EXCLUDED.params, deleted = false`)
+		if err != nil {
+			return fmt.Errorf("prepare set asset, %v", err)
+		}
+		defer setacfg.Close()
+		getacfg, err := tx.Prepare(`SELECT params FROM asset WHERE index = $1`)
+		if err != nil {
+			return fmt.Errorf("prepare get asset, %v", err)
+		}
+		defer getacfg.Close()
+
+		//////////////////
+		// Asset Freeze //
+		//////////////////
+		fr, err := tx.Prepare(`INSERT INTO account_asset (addr, assetid, amount, frozen, created_at, deleted) VALUES ($1, $2, 0, $3, $4, false) ON CONFLICT (addr, assetid) DO UPDATE SET frozen = EXCLUDED.frozen, deleted = false`)
+		if err != nil {
+			return fmt.Errorf("prepare asset freeze, %v", err)
+		}
+		defer fr.Close()
+
 		for _, subround := range updates.AssetUpdates {
 			for addr, aulist := range subround {
 				for _, au := range aulist {
@@ -920,79 +913,101 @@ ON CONFLICT (addr, assetid) DO UPDATE SET amount = account_asset.amount + EXCLUD
 					}
 
 					// Apply deltas
-					if au.Delta.IsInt64() {
-						// easy case
-						delta := au.Delta.Int64()
-						// don't skip delta == 0; mark opt-in
-						_, err = seta.Exec(addr[:], au.AssetID, delta, au.DefaultFrozen, round)
-						if err != nil {
-							return fmt.Errorf("update account asset, %v", err)
-						}
-					} else {
-						sign := au.Delta.Sign()
-						var mi big.Int
-						var step int64
-						if sign > 0 {
-							mi.SetInt64(math.MaxInt64)
-							step = math.MaxInt64
-						} else if sign < 0 {
-							mi.SetInt64(math.MinInt64)
-							step = math.MinInt64
-						} else {
-							continue
-						}
-						for !au.Delta.IsInt64() {
-							_, err = seta.Exec(addr[:], au.AssetID, step, au.DefaultFrozen, round)
+					if au.Transfer != nil {
+						if au.Transfer.Delta.IsInt64() {
+							// easy case
+							delta := au.Transfer.Delta.Int64()
+							// don't skip delta == 0; mark opt-in
+							_, err = seta.Exec(addr[:], au.AssetID, delta, au.DefaultFrozen, round)
 							if err != nil {
 								return fmt.Errorf("update account asset, %v", err)
 							}
-							au.Delta.Sub(&au.Delta, &mi)
-						}
-						sign = au.Delta.Sign()
-						if sign != 0 {
-							_, err = seta.Exec(addr[:], au.AssetID, au.Delta.Int64(), au.DefaultFrozen, round)
-							if err != nil {
-								return fmt.Errorf("update account asset, %v", err)
+						} else {
+							sign := au.Transfer.Delta.Sign()
+							var mi big.Int
+							var step int64
+							if sign > 0 {
+								mi.SetInt64(math.MaxInt64)
+								step = math.MaxInt64
+							} else if sign < 0 {
+								mi.SetInt64(math.MinInt64)
+								step = math.MinInt64
+							} else {
+								continue
+							}
+							for !au.Transfer.Delta.IsInt64() {
+								_, err = seta.Exec(addr[:], au.AssetID, step, au.DefaultFrozen, round)
+								if err != nil {
+									return fmt.Errorf("update account asset, %v", err)
+								}
+								au.Transfer.Delta.Sub(&au.Transfer.Delta, &mi)
+							}
+							sign = au.Transfer.Delta.Sign()
+							if sign != 0 {
+								_, err = seta.Exec(addr[:], au.AssetID, au.Transfer.Delta.Int64(), au.DefaultFrozen, round)
+								if err != nil {
+									return fmt.Errorf("update account asset, %v", err)
+								}
 							}
 						}
 					}
 
 					// Close holding before continuing to next subround.
-					if au.Closed != nil {
-						_, err = acc.Exec(au.Closed.Round, au.Closed.Offset, au.Closed.Sender[:], au.Closed.AssetID)
+					if au.Close != nil {
+						_, err = acc.Exec(au.Close.Round, au.Close.Offset, au.Close.Sender[:], au.AssetID)
 						if err != nil {
 							return fmt.Errorf("asset close record amount, %v", err)
 						}
-						_, err = acs.Exec(au.Closed.CloseTo[:], au.Closed.AssetID, au.Closed.DefaultFrozen, au.Closed.Sender[:], au.Closed.AssetID, round)
+						_, err = acs.Exec(au.Close.CloseTo[:], au.AssetID, au.DefaultFrozen, au.Close.Sender[:], au.AssetID, round)
 						if err != nil {
 							return fmt.Errorf("asset close send, %v", err)
 						}
-						_, err = acd.Exec(round, au.Closed.Sender[:], au.Closed.AssetID)
+						_, err = acd.Exec(round, au.Close.Sender[:], au.AssetID)
 						if err != nil {
 							return fmt.Errorf("asset close del, %v", err)
 						}
 					}
+
+					// Asset Config
+					if au.Config != nil {
+						var outparams string
+						if au.Config.IsNew {
+							outparams = string(json.Encode(au.Config.Params))
+						} else {
+							row := getacfg.QueryRow(au.AssetID)
+							var paramjson []byte
+							err = row.Scan(&paramjson)
+							if err != nil {
+								return fmt.Errorf("get acfg %d, %v", au.AssetID, err)
+							}
+							var old atypes.AssetParams
+							err = json.Decode(paramjson, &old)
+							if err != nil {
+								return fmt.Errorf("bad acgf json %d, %v", au.AssetID, err)
+							}
+							np := types.MergeAssetConfig(old, au.Config.Params)
+							outparams = string(json.Encode(np))
+						}
+						_, err = setacfg.Exec(au.AssetID, au.Config.Creator[:], outparams, round)
+						if err != nil {
+							return fmt.Errorf("update asset, %v", err)
+						}
+					}
+
+					// Asset Freeze
+					if au.Freeze != nil {
+						if au.AssetID == debugAsset {
+							db.log.Errorf("%d %s %s", round, b64(addr[:]), obs(au.Freeze))
+						}
+						_, err = fr.Exec(addr[:], au.AssetID, au.Freeze.Frozen, round)
+						if err != nil {
+							return fmt.Errorf("update asset freeze, %v", err)
+						}
+					}
 				}
 			}
 		}
 	}
-	if len(updates.FreezeUpdates) > 0 {
-		any = true
-		fr, err := tx.Prepare(`INSERT INTO account_asset (addr, assetid, amount, frozen, created_at, deleted) VALUES ($1, $2, 0, $3, $4, false) ON CONFLICT (addr, assetid) DO UPDATE SET frozen = EXCLUDED.frozen, deleted = false`)
-		if err != nil {
-			return fmt.Errorf("prepare asset freeze, %v", err)
-		}
-		defer fr.Close()
-		for _, fs := range updates.FreezeUpdates {
-			if fs.AssetID == debugAsset {
-				db.log.Errorf("%d %s %s", round, b64(fs.Addr[:]), obs(fs))
-			}
-			_, err = fr.Exec(fs.Addr[:], fs.AssetID, fs.Frozen, round)
-			if err != nil {
-				return fmt.Errorf("update asset freeze, %v", err)
-			}
-		}
-	}
 	if len(updates.AssetDestroys) > 0 {
 		// Note! leaves `asset` and `account_asset` rows present for historical reference, but deletes all holdings from all accounts
 		any = true
diff --git a/importer/helper.go b/importer/helper.go
index 8d23d23..7c0abd7 100644
--- a/importer/helper.go
+++ b/importer/helper.go
@@ -23,12 +23,13 @@ import (
 )
 
 // NewImportHelper builds an ImportHelper
-func NewImportHelper(genesisJSONPath string, numRoundsLimit, blockFileLimite int, l *log.Logger) *ImportHelper {
+func NewImportHelper(defaultFrozenCache map[uint64]bool, genesisJSONPath string, numRoundsLimit, blockFileLimite int, l *log.Logger) *ImportHelper {
 	return &ImportHelper{
-		GenesisJSONPath: genesisJSONPath,
-		NumRoundsLimit:  numRoundsLimit,
-		BlockFileLimit:  blockFileLimite,
-		Log:             l,
+		DefaultFrozenCache: defaultFrozenCache,
+		GenesisJSONPath:    genesisJSONPath,
+		NumRoundsLimit:     numRoundsLimit,
+		BlockFileLimit:     blockFileLimite,
+		Log:                l,
 	}
 }
 
@@ -43,6 +44,9 @@ type ImportHelper struct {
 	// BlockFileLimit is the number of block files to process.
 	BlockFileLimit int
 
+	// DefaultFrozenCache is a persistent cache of default frozen values.
+	DefaultFrozenCache map[uint64]bool
+
 	Log *log.Logger
 }
 
@@ -92,7 +96,7 @@ func (h *ImportHelper) Import(db idb.IndexerDb, args []string) {
 		h.Log.Infof("%d blocks in %s, %.0f/s, %d txn, %.0f/s", blocks, dt.String(), float64(time.Second)*float64(blocks)/float64(dt), txCount, float64(time.Second)*float64(txCount)/float64(dt))
 	}
 
-	accountingRounds, txnCount := updateAccounting(db, 0, h.GenesisJSONPath, h.NumRoundsLimit, h.Log)
+	accountingRounds, txnCount := updateAccounting(db, h.DefaultFrozenCache, 0, h.GenesisJSONPath, h.NumRoundsLimit, h.Log)
 
 	accountingdone := time.Now()
 	if accountingRounds > 0 {
@@ -220,11 +224,11 @@ func loadGenesis(db idb.IndexerDb, in io.Reader) (err error) {
 }
 
 // UpdateAccounting triggers an accounting update.
-func UpdateAccounting(db idb.IndexerDb, round uint64, genesisJSONPath string, l *log.Logger) (rounds, txnCount int) {
-	return updateAccounting(db, round, genesisJSONPath, 0, l)
+func UpdateAccounting(db idb.IndexerDb, frozenCache map[uint64]bool, round uint64, genesisJSONPath string, l *log.Logger) (rounds, txnCount int) {
+	return updateAccounting(db, frozenCache, round, genesisJSONPath, 0, l)
 }
 
-func updateAccounting(db idb.IndexerDb, round uint64, genesisJSONPath string, numRoundsLimit int, l *log.Logger) (rounds, txnCount int) {
+func updateAccounting(db idb.IndexerDb, frozenCache map[uint64]bool, round uint64, genesisJSONPath string, numRoundsLimit int, l *log.Logger) (rounds, txnCount int) {
 	rounds = 0
 	txnCount = 0
 	stateJSONStr, err := db.GetMetastate("state")
@@ -252,7 +256,7 @@ func updateAccounting(db idb.IndexerDb, round uint64, genesisJSONPath string, nu
 	}
 
 	lastlog := time.Now()
-	act := accounting.New()
+	act := accounting.New(frozenCache)
 	txns := db.YieldTxns(context.Background(), state.AccountRound)
 	currentRound := uint64(0)
 	roundsSeen := 0


Test patch:
diff --git a/accounting/accounting_test.go b/accounting/accounting_test.go
index 2428cbc..d280437 100644
--- a/accounting/accounting_test.go
+++ b/accounting/accounting_test.go
@@ -11,7 +11,7 @@ import (
 )
 
 func GetAccounting() *State {
-	accountingState := New()
+	accountingState := New(make(map[uint64]bool))
 	accountingState.InitRoundParts(test.Round, test.FeeAddr, test.RewardAddr, 0)
 	return accountingState
 }
@@ -124,13 +124,13 @@ func TestAssetCloseReopenPay(t *testing.T) {
 
 	// Subround 1 has 1 update (the close)
 	assert.Len(t, state.RoundUpdates.AssetUpdates[0], 1)
-	assert.Equal(t, state.RoundUpdates.AssetUpdates[0][test.AccountA][0].Delta.Int64(), int64(0))
-	assert.NotNil(t, state.RoundUpdates.AssetUpdates[0][test.AccountA][0].Closed)
+	assert.Nil(t, state.RoundUpdates.AssetUpdates[0][test.AccountA][0].Transfer)
+	assert.NotNil(t, state.RoundUpdates.AssetUpdates[0][test.AccountA][0].Close)
 
 	// Subround 2 has 2 updates (debit one account, credit the other)
 	assert.Len(t, state.RoundUpdates.AssetUpdates[1], 2)
-	assert.Equal(t, state.RoundUpdates.AssetUpdates[1][test.AccountA][0].Delta.Int64(), int64(amt))
-	assert.Equal(t, state.RoundUpdates.AssetUpdates[1][test.AccountB][0].Delta.Int64(), int64(amt) * -1)
+	assert.Equal(t, state.RoundUpdates.AssetUpdates[1][test.AccountA][0].Transfer.Delta.Int64(), int64(amt))
+	assert.Equal(t, state.RoundUpdates.AssetUpdates[1][test.AccountB][0].Transfer.Delta.Int64(), int64(amt) * -1)
 }
 
 // TestAssetCloseWithAmount checks that close with an amount creates a delta
@@ -147,8 +147,8 @@ func TestAssetCloseWithAmountReopenPay(t *testing.T) {
 
 	// Subround 1 has 2 updates (one debit, one close)
 	assert.Len(t, state.RoundUpdates.AssetUpdates[0], 2)
-	assert.Equal(t, int64(amt) * -1, state.RoundUpdates.AssetUpdates[0][test.AccountA][0].Delta.Int64())
-	assert.NotNil(t, state.RoundUpdates.AssetUpdates[0][test.AccountA][1].Closed)
+	assert.Equal(t, int64(amt) * -1, state.RoundUpdates.AssetUpdates[0][test.AccountA][0].Transfer.Delta.Int64())
+	assert.NotNil(t, state.RoundUpdates.AssetUpdates[0][test.AccountA][1].Close)
 
 	// Subround 2 is empty
 	assert.Len(t, state.RoundUpdates.AssetUpdates[1], 0)
diff --git a/idb/postgres/postgres_integration_test.go b/idb/postgres/postgres_integration_test.go
index 2225369..128599d 100644
--- a/idb/postgres/postgres_integration_test.go
+++ b/idb/postgres/postgres_integration_test.go
@@ -20,9 +20,9 @@ import (
 )
 
 // getAccounting initializes the ac counting state for testing.
-func getAccounting() *accounting.State {
-	accountingState := accounting.New()
-	accountingState.InitRoundParts(test.Round, test.FeeAddr, test.RewardAddr, 0)
+func getAccounting(round uint64, cache map[uint64]bool) *accounting.State {
+	accountingState := accounting.New(cache)
+	accountingState.InitRoundParts(round, test.FeeAddr, test.RewardAddr, 0)
 	return accountingState
 }
 
@@ -77,6 +77,18 @@ func TestMaxRoundOnUninitializedDB(t *testing.T) {
 	assert.Equal(t, uint64(0), round)
 }
 
+func assertAccountAsset(t *testing.T, db *sql.DB, addr types.Address, assetid uint64, frozen bool, amount uint64) {
+	var row *sql.Row
+	var f bool
+	var a uint64
+
+	row = db.QueryRow(`SELECT frozen, amount FROM account_asset as a WHERE a.addr = $1 AND assetid = $2`, addr[:], assetid)
+	err := row.Scan(&f, &a)
+	assert.NoError(t, err, "failed looking up AccountA.")
+	assert.Equal(t, frozen, f)
+	assert.Equal(t, amount, a)
+}
+
 // TestAssetCloseReopenTransfer tests a scenario that requires asset subround accounting
 func TestAssetCloseReopenTransfer(t *testing.T) {
 	db, connStr, shutdownFunc := setupPostgres(t)
@@ -87,16 +99,21 @@ func TestAssetCloseReopenTransfer(t *testing.T) {
 
 	assetid := uint64(2222)
 	amt := uint64(10000)
+	total := uint64(1000000)
 
 	///////////
 	// Given // A round scenario requiring subround accounting: AccountA is funded, closed, opts back, and funded again.
 	///////////
+	_, createAsset := test.MakeAssetConfigOrPanic(test.Round, assetid, total, uint64(6), false, "icicles", "frozen coin", "http://antarctica.com", test.AccountD)
 	_, fundMain := test.MakeAssetTxnOrPanic(test.Round, assetid, amt, test.AccountD, test.AccountA, types.ZeroAddress)
 	_, closeMain := test.MakeAssetTxnOrPanic(test.Round, assetid, 1000, test.AccountA, test.AccountB, test.AccountC)
 	_, optinMain := test.MakeAssetTxnOrPanic(test.Round, assetid, 0, test.AccountA, test.AccountA, types.ZeroAddress)
 	_, payMain := test.MakeAssetTxnOrPanic(test.Round, assetid, amt, test.AccountD, test.AccountA, types.ZeroAddress)
 
-	state := getAccounting()
+	cache, err := pdb.GetDefaultFrozen()
+	assert.NoError(t, err)
+	state := getAccounting(test.Round, cache)
+	state.AddTransaction(createAsset)
 	state.AddTransaction(fundMain)
 	state.AddTransaction(closeMain)
 	state.AddTransaction(optinMain)
@@ -105,39 +122,197 @@ func TestAssetCloseReopenTransfer(t *testing.T) {
 	//////////
 	// When // We commit the round accounting to the database.
 	//////////
-	pdb.CommitRoundAccounting(state.RoundUpdates, test.Round, &itypes.Block{})
+	err = pdb.CommitRoundAccounting(state.RoundUpdates, test.Round, &itypes.Block{})
+	assert.NoError(t, err, "failed to commit")
 
 	//////////
 	// Then // Accounts A, B, C and D have the correct balances.
 	//////////
-	var resultBalance int
-	var row *sql.Row
+	// A has the final payment after being closed out
+	assertAccountAsset(t, db, test.AccountA, assetid, false, amt)
+	// B has the closing transfer amount
+	assertAccountAsset(t, db, test.AccountB, assetid, false, 1000)
+	// C has the close-to remainder
+	assertAccountAsset(t, db, test.AccountC, assetid, false, 9000)
+	// D has the total minus both payments to A
+	assertAccountAsset(t, db, test.AccountD, assetid, false, total - 2 * amt)
+}
 
-	// AccountA should contain the final payment.
-	row = db.QueryRow(`SELECT amount FROM account_asset WHERE account_asset.assetid = $1 AND account_asset.addr = $2`, assetid, test.AccountA[:])
-	err = row.Scan(&resultBalance)
-	assert.NoError(t, err, "checking balance")
-	assert.Equal(t, int(amt), resultBalance)
+// TestDefaultFrozenAndCache checks that values are added to the default frozen cache, and that the cache is used when
+// accounts optin to an asset.
+func TestDefaultFrozenAndCache(t *testing.T) {
+	db, connStr, shutdownFunc := setupPostgres(t)
+	defer shutdownFunc()
 
-	// AccountB should have the asset close amount of 1000
-	row = db.QueryRow(`SELECT amount FROM account_asset WHERE account_asset.assetid = $1 AND account_asset.addr = $2`, assetid, test.AccountB[:])
-	err = row.Scan(&resultBalance)
-	assert.NoError(t, err, "checking balance")
-	assert.Equal(t, 1000, resultBalance)
+	pdb, err := idb.IndexerDbByName("postgres", connStr, nil, nil)
+	assert.NoError(t, err)
 
-	// AccountC should have the remaining 9000
-	row = db.QueryRow(`SELECT amount FROM account_asset WHERE account_asset.assetid = $1 AND account_asset.addr = $2`, assetid, test.AccountC[:])
-	err = row.Scan(&resultBalance)
-	assert.NoError(t, err, "checking balance")
-	assert.Equal(t, 9000, resultBalance)
+	assetid := uint64(2222)
+	total := uint64(1000000)
 
-	// The funding account, AccountD, should have -20000, it sent funds without ever being funded.
-	row = db.QueryRow(`SELECT amount FROM account_asset WHERE account_asset.assetid = $1 AND account_asset.addr = $2`, assetid, test.AccountD[:])
-	err = row.Scan(&resultBalance)
-	assert.NoError(t, err, "checking balance")
-	assert.Equal(t, int(amt)*-2, resultBalance)
+	///////////
+	// Given // A new asset with default-frozen = true, and AccountB opting into it.
+	///////////
+	_, createAssetFrozen := test.MakeAssetConfigOrPanic(test.Round, assetid, total, uint64(6), true, "icicles", "frozen coin", "http://antarctica.com", test.AccountA)
+	_, createAssetNotFrozen := test.MakeAssetConfigOrPanic(test.Round, assetid + 1, total, uint64(6), false, "icicles", "frozen coin", "http://antarctica.com", test.AccountA)
+	_, optinB1 := test.MakeAssetTxnOrPanic(test.Round, assetid, 0, test.AccountB, test.AccountB, types.ZeroAddress)
+	_, optinB2 := test.MakeAssetTxnOrPanic(test.Round, assetid + 1, 0, test.AccountB, test.AccountB, types.ZeroAddress)
+
+
+	cache, err := pdb.GetDefaultFrozen()
+	assert.NoError(t, err)
+	state := getAccounting(test.Round, cache)
+	state.AddTransaction(createAssetFrozen)
+	state.AddTransaction(createAssetNotFrozen)
+	state.AddTransaction(optinB1)
+	state.AddTransaction(optinB2)
+
+	//////////
+	// When // We commit the round accounting to the database.
+	//////////
+	err = pdb.CommitRoundAccounting(state.RoundUpdates, test.Round, &itypes.Block{})
+	assert.NoError(t, err, "failed to commit")
+
+	//////////
+	// Then // Make sure the accounts have the correct default-frozen after create/optin
+	//////////
+	// default-frozen = true
+	assertAccountAsset(t, db, test.AccountA, assetid, true, total)
+	assertAccountAsset(t, db, test.AccountB, assetid, true, 0)
+
+	// default-frozen = false
+	assertAccountAsset(t, db, test.AccountA, assetid + 1, false, total)
+	assertAccountAsset(t, db, test.AccountB, assetid + 1, false, 0)
+}
+
+// TestInitializeFrozenCache checks that the frozen cache is properly initialized on startup.
+func TestInitializeFrozenCache(t *testing.T) {
+	db, connStr, shutdownFunc := setupPostgres(t)
+	defer shutdownFunc()
+
+	// Initialize DB by creating one of these things.
+	_, err := idb.IndexerDbByName("postgres", connStr, nil, nil)
+	assert.NoError(t, err)
+
+	// Add some assets
+	db.Exec(`INSERT INTO asset (index, creator_addr, params) values ($1, $2, $3)`, 1, test.AccountA[:], `{"df":true}`)
+	db.Exec(`INSERT INTO asset (index, creator_addr, params) values ($1, $2, $3)`, 2, test.AccountA[:], `{"df":false}`)
+	db.Exec(`INSERT INTO asset (index, creator_addr, params) values ($1, $2, $3)`, 3, test.AccountA[:], `{}`)
+
+	pdb, err := OpenPostgres(connStr, nil, nil)
+	assert.NoError(t, err)
+	cache, err := pdb.GetDefaultFrozen()
+	assert.NoError(t, err)
+
+	assert.Len(t, cache, 1)
+	assert.True(t, cache[1])
+	assert.False(t, cache[2])
+	assert.False(t, cache[3])
+	assert.False(t, cache[300000])
+}
+
+// TestReCreateAssetHolding checks that the optin value of a defunct
+func TestReCreateAssetHolding(t *testing.T) {
+	db, connStr, shutdownFunc := setupPostgres(t)
+	defer shutdownFunc()
+
+	pdb, err := idb.IndexerDbByName("postgres", connStr, nil, nil)
+	assert.NoError(t, err)
+
+	assetid := uint64(2222)
+	total := uint64(1000000)
+
+	tests := []struct{
+		offset uint64
+		frozen bool
+	}{
+		{
+			offset: 0,
+			frozen: true,
+		},
+		{
+			offset: 1,
+			frozen: false,
+		},
+	}
+
+	for _, testcase := range tests {
+		round := test.Round + testcase.offset
+		aid := assetid + testcase.offset
+		///////////
+		// Given // A new asset with default-frozen, AccountB opts-in and has its frozen state toggled.
+		/////////// Then AccountB opts-out then opts-in again.
+		_, createAssetFrozen := test.MakeAssetConfigOrPanic(round, aid, total, uint64(6), testcase.frozen, "icicles", "frozen coin", "http://antarctica.com", test.AccountA)
+		_, optinB := test.MakeAssetTxnOrPanic(round, aid, 0, test.AccountB, test.AccountB, types.ZeroAddress)
+		_, unfreezeB := test.MakeAssetFreezeOrPanic(round, aid, !testcase.frozen, test.AccountB)
+		_, optoutB := test.MakeAssetTxnOrPanic(round, aid, 0, test.AccountB, test.AccountC, test.AccountD)
+
+		cache, err := pdb.GetDefaultFrozen()
+		assert.NoError(t, err)
+		state := getAccounting(round, cache)
+		state.AddTransaction(createAssetFrozen)
+		state.AddTransaction(optinB)
+		state.AddTransaction(unfreezeB)
+		state.AddTransaction(optoutB)
+		state.AddTransaction(optinB) // reuse optinB
+
+		//////////
+		// When // We commit the round accounting to the database.
+		//////////
+		err = pdb.CommitRoundAccounting(state.RoundUpdates, round, &itypes.Block{})
+		assert.NoError(t, err, "failed to commit")
+
+		//////////
+		// Then // AccountB should have its frozen state set back to the default value
+		//////////
+		assertAccountAsset(t, db, test.AccountB, aid, testcase.frozen, 0)
+	}
 }
 
+// TestMultipleAssetOptins make sure no-op transactions don't reset the default frozen value.
+func TestNoopOptins(t *testing.T) {
+	db, connStr, shutdownFunc := setupPostgres(t)
+	defer shutdownFunc()
+
+	///////////
+	// Given // An asset with default-frozen = true, AccountB opt's in, is unfrozen, then has a no-op opt-in
+	///////////
+
+	assetid := uint64(2222)
+	// create asst
+	//db.Exec(`INSERT INTO asset (index, creator_addr, params) values ($1, $2, $3)`, assetid, test.AccountA[:], `{"df":true}`)
+
+	pdb, err := idb.IndexerDbByName("postgres", connStr, nil, nil)
+	assert.NoError(t, err)
+
+	_, createAsset := test.MakeAssetConfigOrPanic(test.Round, assetid, uint64(1000000), uint64(6), true, "icicles", "frozen coin", "http://antarctica.com", test.AccountD)
+	_, optinB := test.MakeAssetTxnOrPanic(test.Round, assetid, 0, test.AccountB, test.AccountB, types.ZeroAddress)
+	_, unfreezeB := test.MakeAssetFreezeOrPanic(test.Round, assetid, false, test.AccountB)
+
+	cache, err := pdb.GetDefaultFrozen()
+	assert.NoError(t, err)
+	state := getAccounting(test.Round, cache)
+	state.AddTransaction(createAsset)
+	state.AddTransaction(optinB)
+	state.AddTransaction(unfreezeB)
+	state.AddTransaction(optinB)
+
+	//////////
+	// When // We commit the round accounting to the database.
+	//////////
+	err = pdb.CommitRoundAccounting(state.RoundUpdates, test.Round, &itypes.Block{})
+	assert.NoError(t, err, "failed to commit")
+
+	//////////
+	// Then // AccountB should have its frozen state set back to the default value
+	//////////
+	// TODO: This isn't working yet
+	assertAccountAsset(t, db, test.AccountB, assetid, false, 0)
+}
+
+
+
+
 // TestMultipleWriters tests that accounting cannot be double committed.
 func TestMultipleWriters(t *testing.T) {
 	db, connStr, shutdownFunc := setupPostgres(t)
@@ -153,7 +328,9 @@ func TestMultipleWriters(t *testing.T) {
 	///////////
 	_, payAccountA := test.MakePayTxnRowOrPanic(test.Round, 1000, amt, 0, 0, 0, 0, test.AccountD, test.AccountA, types.ZeroAddress)
 
-	state := getAccounting()
+	cache, err := pdb.GetDefaultFrozen()
+	assert.NoError(t, err)
+	state := getAccounting(test.Round, cache)
 	state.AddTransaction(payAccountA)
 
 	//////////
diff --git a/util/test/account_testutil.go b/util/test/account_testutil.go
index caa7aaa..6336635 100644
--- a/util/test/account_testutil.go
+++ b/util/test/account_testutil.go
@@ -55,6 +55,77 @@ func DecodeAddressOrPanic(addr string) types.Address {
 	return result
 }
 
+// MakeAssetConfigOrPanic is a helper to ensure test asset config are initialized.
+func MakeAssetConfigOrPanic(round, assetid, total, decimals uint64, defaultFrozen bool, unitName, assetName, url string, addr types.Address) (*types.SignedTxnWithAD, *idb.TxnRow) {
+	txn := types.SignedTxnWithAD{
+		SignedTxn: types.SignedTxn{
+			Txn: types.Transaction{
+				Type: "acfg",
+				Header: types.Header{
+					Sender:     addr,
+					Fee:        types.MicroAlgos(1000),
+					FirstValid: types.Round(round),
+					LastValid:  types.Round(round),
+				},
+				AssetConfigTxnFields: types.AssetConfigTxnFields{
+					ConfigAsset: 0,
+					AssetParams: types.AssetParams{
+						Total:         total,
+						Decimals:      uint32(decimals),
+						DefaultFrozen: defaultFrozen,
+						UnitName:      unitName,
+						AssetName:     assetName,
+						URL:           url,
+						MetadataHash:  [32]byte{},
+						Manager:       addr,
+						Reserve:       addr,
+						Freeze:        addr,
+						Clawback:      addr,
+					},
+				},
+			},
+		},
+	}
+
+	txnRow := idb.TxnRow{
+		Round:    uint64(txn.Txn.FirstValid),
+		TxnBytes: msgpack.Encode(txn),
+		AssetID:  assetid,
+	}
+
+	return &txn, &txnRow
+}
+
+// MakeAssetFreezeOrPanic is a helper to ensure test asset transactions are initialized.
+func MakeAssetFreezeOrPanic(round, assetid uint64, frozen bool, addr types.Address) (*types.SignedTxnWithAD, *idb.TxnRow) {
+	txn := types.SignedTxnWithAD{
+		SignedTxn: types.SignedTxn{
+			Txn: types.Transaction{
+				Type: "afrz",
+				Header: types.Header{
+					Sender:     addr,
+					Fee:        types.MicroAlgos(1000),
+					FirstValid: types.Round(round),
+					LastValid:  types.Round(round),
+				},
+				AssetFreezeTxnFields: types.AssetFreezeTxnFields{
+					FreezeAccount: addr,
+					FreezeAsset:   types.AssetIndex(assetid),
+					AssetFrozen:   frozen,
+				},
+			},
+		},
+	}
+
+	txnRow := idb.TxnRow{
+		Round:    uint64(txn.Txn.FirstValid),
+		TxnBytes: msgpack.Encode(txn),
+		AssetID:  assetid,
+	}
+
+	return &txn, &txnRow
+}
+
 // MakeAssetTxnOrPanic is a helper to ensure test asset transactions are initialized.
 func MakeAssetTxnOrPanic(round, assetid, amt uint64, sender, receiver, close types.Address) (*types.SignedTxnWithAD, *idb.TxnRow) {
 	txn := types.SignedTxnWithAD{