Repository: tablelandnetwork/go-tableland (go)

Issue:
[GOT-92] The Validator API endpoint to get a transaction receipt returns `table_id`, when there is potentially many tables associated with a transaction.
We should consider changing the schema of the JSON data returned from /receipt/{chainId}/{transactionHash} to allow sending back all of the table IDs associated with the transaction.

Specifically this line would need to change from `type: string` to `type: array` with `items` as `type: string`.

[https://github.com/tablelandnetwork/docs/blob/main/specs/validator/tableland-openapi-spec.yaml#L248](https://github.com/tablelandnetwork/docs/blob/main/specs/validator/tableland-openapi-spec.yaml#L248)

The underlying system database table, `system_txn_receipts`, may or may not need to change.  We could potentially change the `table_id` column to accommodate a list of IDs, or perhaps we could insert a row for each unique combination of chain_id, txn_hash, and table_id.  This might already be how the table was designed?

I've marked this as a bug, but maybe it should be a feature request.  My thinking is that since we have been supporting transactions that support mutating more than one table since August 2022, we should have been returning all of the table IDs. However the only way to create this type of transaction was via direct contract to contract interaction with the Registry, and maybe aquiring all of the table IDs is a feature?

<sub>From [SyncLinear.com](https://synclinear.com) | [GOT-92](https://linear.app/tableland/issue/GOT-92/[got-93]-the-validator-api-endpoint-to-get-a-transaction-receipt)</sub>

Pull request description:
fixes the response of receipt api call
# Summary

- This PR implements the [new API spec (1.0.1)](https://github.com/tablelandnetwork/docs/pull/112).

# Context

Fixes #522 

# Implementation overview

- Most of the changes are to reflect the passage of the `TableIds` data through multiple layers
- Adds a new migration to store `table_ids` in the `system_txn_receipts` table
- It's hard to create an e2e test of this feature. I've added a test at the controller level with a mocked gateway. 


Patch:
diff --git a/cmd/api/main.go b/cmd/api/main.go
index 57732e0..40c6c95 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -30,7 +30,6 @@ import (
 	"github.com/textileio/go-tableland/pkg/database"
 	"github.com/textileio/go-tableland/pkg/eventprocessor"
 	"github.com/textileio/go-tableland/pkg/eventprocessor/eventfeed"
-
 	"go.opentelemetry.io/otel/attribute"
 
 	efimpl "github.com/textileio/go-tableland/pkg/eventprocessor/eventfeed/impl"
@@ -72,11 +71,6 @@ func main() {
 		path.Join(dirPath, "database.db"),
 	)
 
-	db, err := database.Open(databaseURL, attribute.String("database", "main"))
-	if err != nil {
-		log.Fatal().Err(err).Msg("opening the read database")
-	}
-
 	// Restore provided backup (if configured).
 	if config.BootstrapBackupURL != "" {
 		if err := restoreBackup(databaseURL, config.BootstrapBackupURL); err != nil {
@@ -84,6 +78,11 @@ func main() {
 		}
 	}
 
+	db, err := database.Open(databaseURL, attribute.String("database", "main"))
+	if err != nil {
+		log.Fatal().Err(err).Msg("opening the read database")
+	}
+
 	// Parser.
 	parser, err := createParser(config.QueryConstraints)
 	if err != nil {
diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index 33e226c..907acd2 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -151,9 +151,12 @@ func (g *GatewayService) GetReceiptByTransactionHash(
 		BlockNumber:   receipt.BlockNumber,
 		IndexInBlock:  receipt.IndexInBlock,
 		TxnHash:       receipt.TxnHash,
-		TableID:       receipt.TableID,
+		TableIDs:      receipt.TableIDs,
 		Error:         receipt.Error,
 		ErrorEventIdx: receipt.ErrorEventIdx,
+
+		// Deprecated
+		TableID: receipt.TableID,
 	}, true, nil
 }
 
@@ -195,14 +198,17 @@ func (g *GatewayService) emptyMetadataImage() string {
 
 // Receipt represents a Tableland receipt.
 type Receipt struct {
-	ChainID      tableland.ChainID
-	BlockNumber  int64
-	IndexInBlock int64
-	TxnHash      string
-
-	TableID       *tables.TableID
+	ChainID       tableland.ChainID
+	BlockNumber   int64
+	IndexInBlock  int64
+	TxnHash       string
+	TableIDs      []tables.TableID
 	Error         *string
 	ErrorEventIdx *int
+
+	// Deprecated: the Receipt must hold information of all tables that were modified by the transaction.
+	// This field was replaced by TableIDs.
+	TableID *tables.TableID
 }
 
 // Table represents a system-wide table stored in Tableland.
diff --git a/internal/gateway/impl/gateway_store.go b/internal/gateway/impl/gateway_store.go
index e5f30ec..89f449b 100644
--- a/internal/gateway/impl/gateway_store.go
+++ b/internal/gateway/impl/gateway_store.go
@@ -144,18 +144,33 @@ func (s *GatewayStore) GetReceipt(
 		IndexInBlock: res.IndexInBlock,
 		TxnHash:      txnHash,
 	}
+
 	if res.Error.Valid {
 		receipt.Error = &res.Error.String
 
 		errorEventIdx := int(res.ErrorEventIdx.Int64)
 		receipt.ErrorEventIdx = &errorEventIdx
 	}
+
 	if res.TableID.Valid {
 		id, err := tables.NewTableIDFromInt64(res.TableID.Int64)
 		if err != nil {
-			return gateway.Receipt{}, false, fmt.Errorf("parsing id to string: %s", err)
+			return gateway.Receipt{}, false, fmt.Errorf("parsing id integer: %s", err)
+		}
+		receipt.TableID = &id // nolint
+	}
+
+	if res.TableIds.Valid {
+		tableIdsStr := strings.Split(res.TableIds.String, ",")
+		tableIds := make([]tables.TableID, len(tableIdsStr))
+		for i, idStr := range tableIdsStr {
+			tableID, err := tables.NewTableID(idStr)
+			if err != nil {
+				return gateway.Receipt{}, false, fmt.Errorf("parsing id string: %s", err)
+			}
+			tableIds[i] = tableID
 		}
-		receipt.TableID = &id
+		receipt.TableIDs = tableIds
 	}
 
 	return receipt, true, nil
diff --git a/internal/router/controllers/apiv1/model_transaction_receipt.go b/internal/router/controllers/apiv1/model_transaction_receipt.go
index bf8d16e..6b3c7e8 100644
--- a/internal/router/controllers/apiv1/model_transaction_receipt.go
+++ b/internal/router/controllers/apiv1/model_transaction_receipt.go
@@ -13,6 +13,8 @@ type TransactionReceipt struct {
 
 	TableId string `json:"table_id,omitempty"`
 
+	TableIds []string `json:"table_ids,omitempty"`
+
 	TransactionHash string `json:"transaction_hash,omitempty"`
 
 	BlockNumber int64 `json:"block_number,omitempty"`
diff --git a/internal/router/controllers/controller.go b/internal/router/controllers/controller.go
index 7356118..7994da9 100644
--- a/internal/router/controllers/controller.go
+++ b/internal/router/controllers/controller.go
@@ -130,14 +130,21 @@ func (c *Controller) GetReceiptByTransactionHash(rw http.ResponseWriter, r *http
 		BlockNumber:     receipt.BlockNumber,
 		ChainId:         int32(receipt.ChainID),
 	}
-	if receipt.TableID != nil {
-		receiptResponse.TableId = receipt.TableID.String()
+	if receipt.TableID != nil { // nolint
+		receiptResponse.TableId = receipt.TableID.String() // nolint
 	}
 	if receipt.Error != nil {
 		receiptResponse.Error_ = *receipt.Error
 		receiptResponse.ErrorEventIdx = int32(*receipt.ErrorEventIdx)
 	}
 
+	ids := make([]string, len(receipt.TableIDs))
+	for i, tblID := range receipt.TableIDs {
+		ids[i] = tblID.String()
+	}
+
+	receiptResponse.TableIds = ids
+
 	rw.Header().Set("Content-Type", "application/json")
 	rw.WriteHeader(http.StatusOK)
 	_ = json.NewEncoder(rw).Encode(receiptResponse)
diff --git a/pkg/database/db/models.go b/pkg/database/db/models.go
index b8287ba..75d7e22 100644
--- a/pkg/database/db/models.go
+++ b/pkg/database/db/models.go
@@ -84,4 +84,5 @@ type SystemTxnReceipt struct {
 	Error         sql.NullString
 	TableID       sql.NullInt64
 	ErrorEventIdx sql.NullInt64
+	TableIds      sql.NullString
 }
diff --git a/pkg/database/db/receipt.sql.go b/pkg/database/db/receipt.sql.go
index ab27757..1709b42 100644
--- a/pkg/database/db/receipt.sql.go
+++ b/pkg/database/db/receipt.sql.go
@@ -10,7 +10,7 @@ import (
 )
 
 const getReceipt = `-- name: GetReceipt :one
-SELECT chain_id, block_number, index_in_block, txn_hash, error, table_id, error_event_idx from system_txn_receipts WHERE chain_id=?1 and txn_hash=?2
+SELECT chain_id, block_number, index_in_block, txn_hash, error, table_id, error_event_idx, table_ids from system_txn_receipts WHERE chain_id=?1 and txn_hash=?2
 `
 
 type GetReceiptParams struct {
@@ -29,6 +29,7 @@ func (q *Queries) GetReceipt(ctx context.Context, arg GetReceiptParams) (SystemT
 		&i.Error,
 		&i.TableID,
 		&i.ErrorEventIdx,
+		&i.TableIds,
 	)
 	return i, err
 }
diff --git a/pkg/database/migrations/005_receipttableids.down.sql b/pkg/database/migrations/005_receipttableids.down.sql
new file mode 100644
index 0000000..d632f05
--- /dev/null
+++ b/pkg/database/migrations/005_receipttableids.down.sql
@@ -0,0 +1,1 @@
+ALTER TABLE system_txn_receipts DROP COLUMN table_ids;
\ No newline at end of file
diff --git a/pkg/database/migrations/005_receipttableids.up.sql b/pkg/database/migrations/005_receipttableids.up.sql
new file mode 100644
index 0000000..4b018cb
--- /dev/null
+++ b/pkg/database/migrations/005_receipttableids.up.sql
@@ -0,0 +1,3 @@
+ALTER TABLE system_txn_receipts ADD table_ids TEXT;
+
+UPDATE system_txn_receipts SET table_ids=cast(table_id as text) WHERE table_id is not null;
\ No newline at end of file
diff --git a/pkg/database/migrations/migrations.go b/pkg/database/migrations/migrations.go
index bb1b6d8..ff8d68e 100644
--- a/pkg/database/migrations/migrations.go
+++ b/pkg/database/migrations/migrations.go
@@ -9,6 +9,8 @@
 // migrations/003_evm_events.up.sql
 // migrations/004_system_id.down.sql
 // migrations/004_system_id.up.sql
+// migrations/005_receipttableids.down.sql
+// migrations/005_receipttableids.up.sql
 package migrations
 
 import (
@@ -100,7 +102,7 @@ func _001_initDownSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "001_init.down.sql", size: 25, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "001_init.down.sql", size: 25, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -120,7 +122,7 @@ func _001_initUpSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "001_init.up.sql", size: 1907, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "001_init.up.sql", size: 1907, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -140,7 +142,7 @@ func _002_receipterroridxDownSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "002_receipterroridx.down.sql", size: 60, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "002_receipterroridx.down.sql", size: 60, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -160,7 +162,7 @@ func _002_receipterroridxUpSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "002_receipterroridx.up.sql", size: 129, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "002_receipterroridx.up.sql", size: 129, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -180,7 +182,7 @@ func _003_evm_eventsDownSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "003_evm_events.down.sql", size: 59, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "003_evm_events.down.sql", size: 59, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -200,7 +202,7 @@ func _003_evm_eventsUpSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "003_evm_events.up.sql", size: 701, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "003_evm_events.up.sql", size: 701, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -220,7 +222,7 @@ func _004_system_idDownSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "004_system_id.down.sql", size: 21, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "004_system_id.down.sql", size: 21, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -240,7 +242,47 @@ func _004_system_idUpSql() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "004_system_id.up.sql", size: 84, mode: os.FileMode(420), modTime: time.Unix(1678739618, 0)}
+	info := bindataFileInfo{name: "004_system_id.up.sql", size: 84, mode: os.FileMode(420), modTime: time.Unix(1682688746, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var __005_receipttableidsDownSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x28\xae\x2c\x2e\x49\xcd\x8d\x2f\xa9\xc8\x8b\x2f\x4a\x4d\x4e\xcd\x2c\x28\x29\x56\x70\x09\xf2\x0f\x50\x70\xf6\xf7\x09\xf5\xf5\x53\x28\x49\x4c\xca\x49\x8d\xcf\x4c\x29\xb6\x06\x04\x00\x00\xff\xff\xa6\x5c\x8c\x10\x36\x00\x00\x00")
+
+func _005_receipttableidsDownSqlBytes() ([]byte, error) {
+	return bindataRead(
+		__005_receipttableidsDownSql,
+		"005_receipttableids.down.sql",
+	)
+}
+
+func _005_receipttableidsDownSql() (*asset, error) {
+	bytes, err := _005_receipttableidsDownSqlBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "005_receipttableids.down.sql", size: 54, mode: os.FileMode(420), modTime: time.Unix(1682951713, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var __005_receipttableidsUpSql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\xf4\x09\x71\x0d\x52\x08\x71\x74\xf2\x71\x55\x28\xae\x2c\x2e\x49\xcd\x8d\x2f\xa9\xc8\x8b\x2f\x4a\x4d\x4e\xcd\x2c\x28\x29\x56\x70\x74\x71\x51\x28\x49\x4c\xca\x49\x8d\xcf\x4c\x29\x56\x08\x71\x8d\x08\xb1\xe6\xe2\x0a\x0d\x70\x71\x0c\xc1\xae\x3e\xd8\x35\x04\xa1\xde\x36\x39\xb1\xb8\x44\x03\xc6\x55\x48\x2c\x56\x28\x49\xad\x28\xd1\x54\x08\xf7\x70\x0d\x72\x85\x2b\x53\xc8\x2c\x56\xc8\xcb\x2f\x51\xc8\x2b\xcd\xc9\xb1\x06\x04\x00\x00\xff\xff\xf0\xad\x75\x24\x90\x00\x00\x00")
+
+func _005_receipttableidsUpSqlBytes() ([]byte, error) {
+	return bindataRead(
+		__005_receipttableidsUpSql,
+		"005_receipttableids.up.sql",
+	)
+}
+
+func _005_receipttableidsUpSql() (*asset, error) {
+	bytes, err := _005_receipttableidsUpSqlBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "005_receipttableids.up.sql", size: 144, mode: os.FileMode(420), modTime: time.Unix(1682951686, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -305,6 +347,8 @@ var _bindata = map[string]func() (*asset, error){
 	"003_evm_events.up.sql":        _003_evm_eventsUpSql,
 	"004_system_id.down.sql":       _004_system_idDownSql,
 	"004_system_id.up.sql":         _004_system_idUpSql,
+	"005_receipttableids.down.sql": _005_receipttableidsDownSql,
+	"005_receipttableids.up.sql":   _005_receipttableidsUpSql,
 }
 
 // AssetDir returns the file names below a certain
@@ -356,6 +400,8 @@ var _bintree = &bintree{nil, map[string]*bintree{
 	"003_evm_events.up.sql":        &bintree{_003_evm_eventsUpSql, map[string]*bintree{}},
 	"004_system_id.down.sql":       &bintree{_004_system_idDownSql, map[string]*bintree{}},
 	"004_system_id.up.sql":         &bintree{_004_system_idUpSql, map[string]*bintree{}},
+	"005_receipttableids.down.sql": &bintree{_005_receipttableidsDownSql, map[string]*bintree{}},
+	"005_receipttableids.up.sql":   &bintree{_005_receipttableidsUpSql, map[string]*bintree{}},
 }}
 
 // RestoreAsset restores an asset under the given directory
diff --git a/pkg/eventprocessor/eventprocessor.go b/pkg/eventprocessor/eventprocessor.go
index cb8ae3c..b030d58 100644
--- a/pkg/eventprocessor/eventprocessor.go
+++ b/pkg/eventprocessor/eventprocessor.go
@@ -78,7 +78,10 @@ type Receipt struct {
 	IndexInBlock int64
 	TxnHash      string
 
-	TableID       *tables.TableID
+	TableIDs      tables.TableIDs
 	Error         *string
 	ErrorEventIdx *int
+
+	// Deprecated
+	TableID *tables.TableID
 }
diff --git a/pkg/eventprocessor/impl/eventprocessor.go b/pkg/eventprocessor/impl/eventprocessor.go
index 0cf9e90..05226be 100644
--- a/pkg/eventprocessor/impl/eventprocessor.go
+++ b/pkg/eventprocessor/impl/eventprocessor.go
@@ -244,14 +244,16 @@ func (ep *EventProcessor) executeBlock(ctx context.Context, block eventfeed.Bloc
 			return fmt.Errorf("executing txn events: %s", err)
 		}
 		receipt := eventprocessor.Receipt{
-			ChainID:      ep.chainID,
-			BlockNumber:  block.BlockNumber,
-			IndexInBlock: int64(idxInBlock),
-			TxnHash:      txnEvents.TxnHash.Hex(),
-
-			TableID:       txnExecResult.TableID,
+			ChainID:       ep.chainID,
+			BlockNumber:   block.BlockNumber,
+			IndexInBlock:  int64(idxInBlock),
+			TxnHash:       txnEvents.TxnHash.Hex(),
+			TableIDs:      txnExecResult.TableIDs,
 			Error:         txnExecResult.Error,
 			ErrorEventIdx: txnExecResult.ErrorEventIdx,
+
+			// Deprecated
+			TableID: txnExecResult.TableID,
 		}
 		receipts = append(receipts, receipt)
 
diff --git a/pkg/eventprocessor/impl/executor/executor.go b/pkg/eventprocessor/impl/executor/executor.go
index 313fe4b..4379dae 100644
--- a/pkg/eventprocessor/impl/executor/executor.go
+++ b/pkg/eventprocessor/impl/executor/executor.go
@@ -55,10 +55,13 @@ type BlockScope interface {
 
 // TxnExecutionResult contains the result of executing a txn with all contained events.
 type TxnExecutionResult struct {
-	TableID *tables.TableID
+	TableIDs []tables.TableID
 
 	Error         *string
 	ErrorEventIdx *int
+
+	// Deprecated
+	TableID *tables.TableID
 }
 
 // StateHash represents the state of the database at given block number for a particular chain id.
diff --git a/pkg/eventprocessor/impl/executor/impl/blockscope.go b/pkg/eventprocessor/impl/executor/impl/blockscope.go
index 1bab319..55f4085 100644
--- a/pkg/eventprocessor/impl/executor/impl/blockscope.go
+++ b/pkg/eventprocessor/impl/executor/impl/blockscope.go
@@ -137,14 +137,23 @@ func (bs *blockScope) SaveTxnReceipts(ctx context.Context, rs []eventprocessor.R
 			tableID.Valid = true
 			tableID.Int64 = r.TableID.ToBigInt().Int64()
 		}
+
 		if r.Error != nil {
 			*r.Error = strings.ToValidUTF8(*r.Error, "")
 		}
+
+		tableIDs := sql.NullString{Valid: false}
+		if len(r.TableIDs) > 0 {
+			tableIDs.Valid = true
+			tableIDs.String = r.TableIDs.String()
+		}
+
 		if _, err := bs.txn.ExecContext(
 			ctx,
-			`INSERT INTO system_txn_receipts (chain_id,txn_hash,error,error_event_idx,table_id,block_number,index_in_block) 
-				 VALUES (?1,?2,?3,?4,?5,?6,?7)`,
-			r.ChainID, r.TxnHash, r.Error, r.ErrorEventIdx, tableID, r.BlockNumber, r.IndexInBlock); err != nil {
+			`INSERT INTO system_txn_receipts 
+				(chain_id,txn_hash,error,error_event_idx,table_id,block_number,index_in_block,table_ids) 
+				VALUES (?1,?2,?3,?4,?5,?6,?7,?8)`,
+			r.ChainID, r.TxnHash, r.Error, r.ErrorEventIdx, tableID, r.BlockNumber, r.IndexInBlock, tableIDs); err != nil {
 			return fmt.Errorf("insert txn receipt: %s", err)
 		}
 	}
diff --git a/pkg/eventprocessor/impl/executor/impl/txnscope.go b/pkg/eventprocessor/impl/executor/impl/txnscope.go
index 763a79c..2a0cc66 100644
--- a/pkg/eventprocessor/impl/executor/impl/txnscope.go
+++ b/pkg/eventprocessor/impl/executor/impl/txnscope.go
@@ -56,6 +56,7 @@ func (ts *txnScope) executeTxnEvents(
 	var res eventExecutionResult
 	var err error
 
+	tableIDs := make([]tables.TableID, 0)
 	for idx, event := range evmTxn.Events {
 		switch event := event.(type) {
 		case *ethereum.ContractRunSQL:
@@ -107,9 +108,16 @@ func (ts *txnScope) executeTxnEvents(
 				ErrorEventIdx: &idx,
 			}, nil
 		}
+
+		if res.TableID != nil {
+			tableIDs = append(tableIDs, *res.TableID)
+		}
 	}
 
-	return executor.TxnExecutionResult{TableID: res.TableID}, nil
+	return executor.TxnExecutionResult{
+		TableID:  res.TableID,
+		TableIDs: tableIDs,
+	}, nil
 }
 
 // AccessControlDTO data structure from database.
diff --git a/pkg/tables/tables.go b/pkg/tables/tables.go
index 6518e19..62f2453 100644
--- a/pkg/tables/tables.go
+++ b/pkg/tables/tables.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"fmt"
 	"math/big"
+	"strings"
 
 	"github.com/ethereum/go-ethereum/common"
 )
@@ -11,6 +12,18 @@ import (
 // TableID is the ID of a Table.
 type TableID big.Int
 
+// TableIDs is a list of TableID.
+type TableIDs []TableID
+
+// String transform a list of TableIds into a string.
+func (ids TableIDs) String() string {
+	tableIdsStr := make([]string, len(ids))
+	for i, tableID := range ids {
+		tableIdsStr[i] = tableID.String()
+	}
+	return strings.Join(tableIdsStr, ",")
+}
+
 // String returns a string representation of the TableID.
 func (tid TableID) String() string {
 	bi := (big.Int)(tid)


Test patch:
diff --git a/internal/router/controllers/controller_test.go b/internal/router/controllers/controller_test.go
index d1b06cc..9c917ab 100644
--- a/internal/router/controllers/controller_test.go
+++ b/internal/router/controllers/controller_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"math/big"
 	"net/http"
 	"net/http/httptest"
 	"strconv"
@@ -17,6 +18,7 @@ import (
 	"github.com/textileio/go-tableland/internal/router/middlewares"
 	"github.com/textileio/go-tableland/internal/tableland"
 	"github.com/textileio/go-tableland/mocks"
+	"github.com/textileio/go-tableland/pkg/tables"
 )
 
 func TestQuery(t *testing.T) {
@@ -244,6 +246,40 @@ func TestTableNotFoundMock(t *testing.T) {
 	require.JSONEq(t, expJSON, rr.Body.String())
 }
 
+func TestReceipt(t *testing.T) {
+	r := mocks.NewGateway(t)
+	r.EXPECT().GetReceiptByTransactionHash(mock.Anything, mock.Anything, mock.Anything).Return(
+		gateway.Receipt{
+			ChainID:       1337,
+			BlockNumber:   1,
+			IndexInBlock:  0,
+			TxnHash:       "0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838",
+			TableIDs:      []tables.TableID{tables.TableID(*big.NewInt(1)), tables.TableID(*big.NewInt(2))},
+			Error:         nil,
+			ErrorEventIdx: nil,
+		},
+		true,
+		nil,
+	)
+
+	ctrl := NewController(r)
+
+	router := mux.NewRouter()
+	router.HandleFunc("/receipt/{chainId}/{transactionHash}", ctrl.GetReceiptByTransactionHash)
+
+	ctx := context.WithValue(context.Background(), middlewares.ContextKeyChainID, tableland.ChainID(1337))
+	// Table format
+	req, err := http.NewRequestWithContext(
+		ctx, "GET", "/receipt/1337/0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838", nil,
+	)
+	require.NoError(t, err)
+	rr := httptest.NewRecorder()
+	router.ServeHTTP(rr, req)
+	require.Equal(t, http.StatusOK, rr.Code)
+	exp := `{"table_ids":["1","2"],"transaction_hash":"0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838","block_number":1,"chain_id":1337}` // nolint
+	require.JSONEq(t, exp, rr.Body.String())
+}
+
 func parseJSONLString(val string) []string {
 	s := strings.TrimRight(val, "\n")
 	return strings.Split(s, "\n")
diff --git a/internal/tableland/impl/tableland_test.go b/internal/tableland/impl/tableland_test.go
index 104e63e..bb695ce 100644
--- a/internal/tableland/impl/tableland_test.go
+++ b/internal/tableland/impl/tableland_test.go
@@ -657,11 +657,11 @@ func requireReceipts(
 		require.NotZero(t, receipt.BlockNumber)
 		if ok {
 			require.Empty(t, receipt.Error)
-			require.NotNil(t, receipt.TableID)
-			require.NotZero(t, receipt.TableID)
+			require.NotNil(t, receipt.TableID)  // nolint
+			require.NotZero(t, receipt.TableID) // nolint
 		} else {
 			require.NotEmpty(t, receipt.Error)
-			require.Nil(t, receipt.TableID)
+			require.Nil(t, receipt.TableID) // nolint
 		}
 	}
 }
diff --git a/pkg/eventprocessor/impl/eventprocessor_replayhistory_test.go b/pkg/eventprocessor/impl/eventprocessor_replayhistory_test.go
index e09b105..bd0fe82 100644
--- a/pkg/eventprocessor/impl/eventprocessor_replayhistory_test.go
+++ b/pkg/eventprocessor/impl/eventprocessor_replayhistory_test.go
@@ -37,14 +37,14 @@ func TestReplayProductionHistory(t *testing.T) {
 	}
 
 	expectedStateHashes := map[tableland.ChainID]string{
-		1:      "bce26781eed109b8aaae2d1f688c134831fdf061",
-		5:      "21684ad97813634841393a097436e397de96692f",
-		10:     "1aa835eec9a9ac08cc2784d9d29df7fb15409d08",
-		69:     "fd1ba648c9406c0af321cb734eb203c742fff2a3",
-		137:    "fd1da780698b394a352b59e9b0c124f9cf010b67",
-		420:    "639dda72b6e4a5a8ef7ceb2b734e0b6ecc241407",
-		80001:  "50d477f69179aa0b027c8df7661666c7b5676699",
-		421613: "d58fd380066628fa92fd8a87831ea744b9ba1d8b",
+		1:      "55880bbeecd247f20c0e75e15b6ddc3a432b46f0",
+		5:      "4d9a36da2718fd0cd2f6664147358d89ab7fafa9",
+		10:     "2151d711fc50a32b7e0cbdebce7f48aa0c274ce5",
+		69:     "b1136bd05118349be32372509f05c240180a93d3",
+		137:    "12b3d0aa62b4e61b10ea81c16bf050f8c27b1dca",
+		420:    "058bd19e7874fa3c9436b0cebbcf5846f7c347f3",
+		80001:  "5c9709607bd9da6e5e80df04e9ec2b30b488793d",
+		421613: "18ae0ef43cdedc548706c61dccae533411f9d514",
 	}
 
 	historyDBURI := getHistoryDBURI(t)
diff --git a/pkg/eventprocessor/impl/eventprocessor_test.go b/pkg/eventprocessor/impl/eventprocessor_test.go
index 418818d..ab63288 100644
--- a/pkg/eventprocessor/impl/eventprocessor_test.go
+++ b/pkg/eventprocessor/impl/eventprocessor_test.go
@@ -58,10 +58,11 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 		txnHashes := contractCalls.runSQL(queries)
 
 		expReceipt := eventprocessor.Receipt{
-			ChainID: chainID,
-			TxnHash: txnHashes[0].String(),
-			Error:   nil,
-			TableID: &tableID,
+			ChainID:  chainID,
+			TxnHash:  txnHashes[0].String(),
+			Error:    nil,
+			TableID:  &tableID,
+			TableIDs: []tables.TableID{tableID},
 		}
 		require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 
@@ -76,10 +77,11 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 		txnHashes := contractCalls.runSQL(queries)
 
 		expReceipt := eventprocessor.Receipt{
-			ChainID: chainID,
-			TxnHash: txnHashes[0].String(),
-			Error:   &expWrongTypeErr,
-			TableID: nil,
+			ChainID:  chainID,
+			TxnHash:  txnHashes[0].String(),
+			Error:    &expWrongTypeErr,
+			TableID:  nil,
+			TableIDs: nil,
 		}
 		require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 
@@ -101,6 +103,7 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 				IndexInBlock: int64(i),
 				Error:        nil,
 				TableID:      &tableID,
+				TableIDs:     []tables.TableID{tableID},
 			}
 		}
 		require.Eventually(t, checkReceipts(t, expReceipts...), time.Second*5, time.Millisecond*100)
@@ -122,6 +125,7 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 			IndexInBlock: 0,
 			Error:        &expWrongTypeErr,
 			TableID:      nil,
+			TableIDs:     nil,
 		}
 		expReceipts[1] = eventprocessor.Receipt{
 			ChainID:      chainID,
@@ -129,6 +133,7 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 			IndexInBlock: 1,
 			Error:        nil,
 			TableID:      &tableID,
+			TableIDs:     []tables.TableID{tableID},
 		}
 		require.Eventually(t, checkReceipts(t, expReceipts...), time.Second*5, time.Millisecond*100)
 
@@ -148,6 +153,7 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 			IndexInBlock: 0,
 			Error:        nil,
 			TableID:      &tableID,
+			TableIDs:     []tables.TableID{tableID},
 		}
 		expReceipts[1] = eventprocessor.Receipt{
 			ChainID:      chainID,
@@ -155,6 +161,7 @@ func TestRunSQLBlockProcessing(t *testing.T) {
 			IndexInBlock: 1,
 			Error:        &expWrongTypeErr,
 			TableID:      nil,
+			TableIDs:     nil,
 		}
 		require.Eventually(t, checkReceipts(t, expReceipts...), time.Second*5, time.Millisecond*100)
 
@@ -176,10 +183,11 @@ func TestCreateTableBlockProcessing(t *testing.T) {
 			tableID, err := tables.NewTableID(strconv.Itoa(i + 2))
 			require.NoError(t, err)
 			expReceipt := eventprocessor.Receipt{
-				ChainID: chainID,
-				TxnHash: txnHash.String(),
-				Error:   nil,
-				TableID: &tableID,
+				ChainID:  chainID,
+				TxnHash:  txnHash.String(),
+				Error:    nil,
+				TableID:  &tableID,
+				TableIDs: []tables.TableID{tableID},
 			}
 			require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 		}
@@ -193,10 +201,11 @@ func TestCreateTableBlockProcessing(t *testing.T) {
 		txnHash := contractCalls.createTable("CREATEZ TABLE Foo_1337 (bar int)")
 
 		expReceipt := eventprocessor.Receipt{
-			ChainID: chainID,
-			TxnHash: txnHash.String(),
-			Error:   &expWrongTypeErr,
-			TableID: nil,
+			ChainID:  chainID,
+			TxnHash:  txnHash.String(),
+			Error:    &expWrongTypeErr,
+			TableID:  nil,
+			TableIDs: nil,
 		}
 		require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 	})
@@ -214,10 +223,11 @@ func TestQueryWithWrongTableTarget(t *testing.T) {
 
 	expErr := "query targets table id 9999 and not 1"
 	expReceipt := eventprocessor.Receipt{
-		ChainID: chainID,
-		TxnHash: txnHashes[0].String(),
-		Error:   &expErr,
-		TableID: nil,
+		ChainID:  chainID,
+		TxnHash:  txnHashes[0].String(),
+		Error:    &expErr,
+		TableID:  nil,
+		TableIDs: nil,
 	}
 	require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 }
@@ -233,10 +243,11 @@ func TestSetController(t *testing.T) {
 
 		tid := tables.TableID(*big.NewInt(1))
 		expReceipt := eventprocessor.Receipt{
-			ChainID: chainID,
-			TxnHash: txnHash.Hex(),
-			Error:   nil,
-			TableID: &tid,
+			ChainID:  chainID,
+			TxnHash:  txnHash.Hex(),
+			Error:    nil,
+			TableID:  &tid,
+			TableIDs: []tables.TableID{tid},
 		}
 		require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 	})
@@ -247,10 +258,11 @@ func TestSetController(t *testing.T) {
 
 		tid := tables.TableID(*big.NewInt(1))
 		expReceipt := eventprocessor.Receipt{
-			ChainID: chainID,
-			TxnHash: txnHash.Hex(),
-			Error:   nil,
-			TableID: &tid,
+			ChainID:  chainID,
+			TxnHash:  txnHash.Hex(),
+			Error:    nil,
+			TableID:  &tid,
+			TableIDs: []tables.TableID{tid},
 		}
 		require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 	})
@@ -265,10 +277,11 @@ func TestTransfer(t *testing.T) {
 	tableID, err := tables.NewTableID("2")
 	require.NoError(t, err)
 	expReceipt := eventprocessor.Receipt{
-		ChainID: chainID,
-		TxnHash: txnHash.String(),
-		Error:   nil,
-		TableID: &tableID,
+		ChainID:  chainID,
+		TxnHash:  txnHash.String(),
+		Error:    nil,
+		TableID:  &tableID,
+		TableIDs: []tables.TableID{tableID},
 	}
 	require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 
@@ -278,10 +291,11 @@ func TestTransfer(t *testing.T) {
 
 		tid := tables.TableID(*big.NewInt(1))
 		expReceipt := eventprocessor.Receipt{
-			ChainID: chainID,
-			TxnHash: txnHash.Hex(),
-			Error:   nil,
-			TableID: &tid,
+			ChainID:  chainID,
+			TxnHash:  txnHash.Hex(),
+			Error:    nil,
+			TableID:  &tid,
+			TableIDs: []tables.TableID{tid},
 		}
 		require.Eventually(t, checkReceipts(t, expReceipt), time.Second*5, time.Millisecond*100)
 	})
@@ -411,7 +425,8 @@ func setup(t *testing.T) (
 				require.Equal(t, expReceipt.IndexInBlock, gotReceipt.IndexInBlock)
 				require.Equal(t, expReceipt.TxnHash, gotReceipt.TxnHash)
 				require.Equal(t, expReceipt.Error, gotReceipt.Error)
-				require.Equal(t, expReceipt.TableID, gotReceipt.TableID)
+				require.Equal(t, expReceipt.TableID, gotReceipt.TableID) // nolint
+				require.Equal(t, expReceipt.TableIDs, tables.TableIDs(gotReceipt.TableIDs))
 			}
 			return true
 		}