diff --git a/clientapi/auth/authtypes/device.go b/clientapi/auth/authtypes/device.go index 0c53f8194..86b51c1e5 100644 --- a/clientapi/auth/authtypes/device.go +++ b/clientapi/auth/authtypes/device.go @@ -22,5 +22,9 @@ type Device struct { // This uniquely identifies the device from all other devices and clients. AccessToken string DisplayName string + // The unique ID of the session identified by the access token. + // Can be used as a secure substitution in places where data needs to be + // associated with access tokens. + SessionID int64 // TODO: last used timestamp, keys, etc } diff --git a/clientapi/auth/storage/devices/devices_table.go b/clientapi/auth/storage/devices/devices_table.go index d64022b61..600c38e09 100644 --- a/clientapi/auth/storage/devices/devices_table.go +++ b/clientapi/auth/storage/devices/devices_table.go @@ -27,11 +27,19 @@ import ( ) const devicesSchema = ` +-- This sequence is used for automatic allocation of session_id. +CREATE SEQUENCE IF NOT EXISTS device_session_id_seq START 1; + -- Stores data about devices. CREATE TABLE IF NOT EXISTS device_devices ( -- The access token granted to this device. This has to be the primary key -- so we can distinguish which device is making a given request. access_token TEXT NOT NULL PRIMARY KEY, + -- The auto-allocated unique ID of the session identified by the access token. + -- This can be used as a secure substitution of the access token in situations + -- where data is associated with access tokens (e.g. transaction storage), + -- so we don't have to store users' access tokens everywhere. + session_id BIGINT NOT NULL DEFAULT nextval('device_session_id_seq'), -- The device identifier. This only needs to uniquely identify a device for a given user, not globally. -- access_tokens will be clobbered based on the device ID for a user. device_id TEXT NOT NULL, @@ -51,10 +59,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS device_localpart_id_idx ON device_devices(loca ` const insertDeviceSQL = "" + - "INSERT INTO device_devices(device_id, localpart, access_token, created_ts, display_name) VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO device_devices(device_id, localpart, access_token, created_ts, display_name) VALUES ($1, $2, $3, $4, $5)" + + " RETURNING session_id" const selectDeviceByTokenSQL = "" + - "SELECT device_id, localpart FROM device_devices WHERE access_token = $1" + "SELECT session_id, device_id, localpart FROM device_devices WHERE access_token = $1" const selectDeviceByIDSQL = "" + "SELECT display_name FROM device_devices WHERE localpart = $1 and device_id = $2" @@ -120,14 +129,16 @@ func (s *devicesStatements) insertDevice( displayName *string, ) (*authtypes.Device, error) { createdTimeMS := time.Now().UnixNano() / 1000000 + var sessionID int64 stmt := common.TxStmt(txn, s.insertDeviceStmt) - if _, err := stmt.ExecContext(ctx, id, localpart, accessToken, createdTimeMS, displayName); err != nil { + if err := stmt.QueryRowContext(ctx, id, localpart, accessToken, createdTimeMS, displayName).Scan(&sessionID); err != nil { return nil, err } return &authtypes.Device{ ID: id, UserID: userutil.MakeUserID(localpart, s.serverName), AccessToken: accessToken, + SessionID: sessionID, }, nil } @@ -161,7 +172,7 @@ func (s *devicesStatements) selectDeviceByToken( var dev authtypes.Device var localpart string stmt := s.selectDeviceByTokenStmt - err := stmt.QueryRowContext(ctx, accessToken).Scan(&dev.ID, &localpart) + err := stmt.QueryRowContext(ctx, accessToken).Scan(&dev.SessionID, &dev.ID, &localpart) if err == nil { dev.UserID = userutil.MakeUserID(localpart, s.serverName) dev.AccessToken = accessToken diff --git a/clientapi/routing/sendevent.go b/clientapi/routing/sendevent.go index 9696b360e..76e36cd46 100644 --- a/clientapi/routing/sendevent.go +++ b/clientapi/routing/sendevent.go @@ -60,18 +60,18 @@ func SendEvent( return *resErr } - var txnAndDeviceID *api.TransactionID + var txnAndSessionID *api.TransactionID if txnID != nil { - txnAndDeviceID = &api.TransactionID{ + txnAndSessionID = &api.TransactionID{ TransactionID: *txnID, - DeviceID: device.ID, + SessionID: device.SessionID, } } // pass the new event to the roomserver and receive the correct event ID // event ID in case of duplicate transaction is discarded eventID, err := producer.SendEvents( - req.Context(), []gomatrixserverlib.Event{*e}, cfg.Matrix.ServerName, txnAndDeviceID, + req.Context(), []gomatrixserverlib.Event{*e}, cfg.Matrix.ServerName, txnAndSessionID, ) if err != nil { return httputil.LogThenError(req, err) diff --git a/federationsender/api/query.go b/federationsender/api/query.go new file mode 100644 index 000000000..ebc6e833f --- /dev/null +++ b/federationsender/api/query.go @@ -0,0 +1,98 @@ +package api + +import ( + "context" + "net/http" + + commonHTTP "github.com/matrix-org/dendrite/common/http" + "github.com/matrix-org/gomatrixserverlib" + + "github.com/matrix-org/dendrite/federationsender/types" + "github.com/opentracing/opentracing-go" +) + +// QueryJoinedHostsInRoomRequest is a request to QueryJoinedHostsInRoom +type QueryJoinedHostsInRoomRequest struct { + RoomID string `json:"room_id"` +} + +// QueryJoinedHostsInRoomResponse is a response to QueryJoinedHostsInRoom +type QueryJoinedHostsInRoomResponse struct { + JoinedHosts []types.JoinedHost `json:"joined_hosts"` +} + +// QueryJoinedHostServerNamesRequest is a request to QueryJoinedHostServerNames +type QueryJoinedHostServerNamesInRoomRequest struct { + RoomID string `json:"room_id"` +} + +// QueryJoinedHostServerNamesResponse is a response to QueryJoinedHostServerNames +type QueryJoinedHostServerNamesInRoomResponse struct { + ServerNames []gomatrixserverlib.ServerName `json:"server_names"` +} + +// FederationSenderQueryAPI is used to query information from the federation sender. +type FederationSenderQueryAPI interface { + // Query the joined hosts and the membership events accounting for their participation in a room. + // Note that if a server has multiple users in the room, it will have multiple entries in the returned slice. + // See `QueryJoinedHostServerNamesInRoom` for a de-duplicated version. + QueryJoinedHostsInRoom( + ctx context.Context, + request *QueryJoinedHostsInRoomRequest, + response *QueryJoinedHostsInRoomResponse, + ) error + // Query the server names of the joined hosts in a room. + // Unlike QueryJoinedHostsInRoom, this function returns a de-duplicated slice + // containing only the server names (without information for membership events). + QueryJoinedHostServerNamesInRoom( + ctx context.Context, + request *QueryJoinedHostServerNamesInRoomRequest, + response *QueryJoinedHostServerNamesInRoomResponse, + ) error +} + +// FederationSenderQueryJoinedHostsInRoomPath is the HTTP path for the QueryJoinedHostsInRoom API. +const FederationSenderQueryJoinedHostsInRoomPath = "/api/federationsender/queryJoinedHostsInRoom" + +// FederationSenderQueryJoinedHostServerNamesInRoomPath is the HTTP path for the QueryJoinedHostServerNamesInRoom API. +const FederationSenderQueryJoinedHostServerNamesInRoomPath = "/api/federationsender/queryJoinedHostServerNamesInRoom" + +// NewFederationSenderQueryAPIHTTP creates a FederationSenderQueryAPI implemented by talking to a HTTP POST API. +// If httpClient is nil then it uses the http.DefaultClient +func NewFederationSenderQueryAPIHTTP(federationSenderURL string, httpClient *http.Client) FederationSenderQueryAPI { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &httpFederationSenderQueryAPI{federationSenderURL, httpClient} +} + +type httpFederationSenderQueryAPI struct { + federationSenderURL string + httpClient *http.Client +} + +// QueryJoinedHostsInRoom implements FederationSenderQueryAPI +func (h *httpFederationSenderQueryAPI) QueryJoinedHostsInRoom( + ctx context.Context, + request *QueryJoinedHostsInRoomRequest, + response *QueryJoinedHostsInRoomResponse, +) error { + span, ctx := opentracing.StartSpanFromContext(ctx, "QueryJoinedHostsInRoom") + defer span.Finish() + + apiURL := h.federationSenderURL + FederationSenderQueryJoinedHostsInRoomPath + return commonHTTP.PostJSON(ctx, span, h.httpClient, apiURL, request, response) +} + +// QueryJoinedHostServerNamesInRoom implements FederationSenderQueryAPI +func (h *httpFederationSenderQueryAPI) QueryJoinedHostServerNamesInRoom( + ctx context.Context, + request *QueryJoinedHostServerNamesInRoomRequest, + response *QueryJoinedHostServerNamesInRoomResponse, +) error { + span, ctx := opentracing.StartSpanFromContext(ctx, "QueryJoinedHostServerNamesInRoom") + defer span.Finish() + + apiURL := h.federationSenderURL + FederationSenderQueryJoinedHostServerNamesInRoomPath + return commonHTTP.PostJSON(ctx, span, h.httpClient, apiURL, request, response) +} diff --git a/federationsender/federationsender.go b/federationsender/federationsender.go index 9b732b386..a318d2099 100644 --- a/federationsender/federationsender.go +++ b/federationsender/federationsender.go @@ -15,11 +15,15 @@ package federationsender import ( + "net/http" + "github.com/matrix-org/dendrite/common/basecomponent" + "github.com/matrix-org/dendrite/federationsender/api" "github.com/matrix-org/dendrite/federationsender/consumers" + "github.com/matrix-org/dendrite/federationsender/query" "github.com/matrix-org/dendrite/federationsender/queue" "github.com/matrix-org/dendrite/federationsender/storage" - "github.com/matrix-org/dendrite/roomserver/api" + roomserverAPI "github.com/matrix-org/dendrite/roomserver/api" "github.com/matrix-org/gomatrixserverlib" "github.com/sirupsen/logrus" ) @@ -29,8 +33,8 @@ import ( func SetupFederationSenderComponent( base *basecomponent.BaseDendrite, federation *gomatrixserverlib.FederationClient, - queryAPI api.RoomserverQueryAPI, -) { + rsQueryAPI roomserverAPI.RoomserverQueryAPI, +) api.FederationSenderQueryAPI { federationSenderDB, err := storage.NewDatabase(string(base.Cfg.Database.FederationSender)) if err != nil { logrus.WithError(err).Panic("failed to connect to federation sender db") @@ -40,7 +44,7 @@ func SetupFederationSenderComponent( rsConsumer := consumers.NewOutputRoomEventConsumer( base.Cfg, base.KafkaConsumer, queues, - federationSenderDB, queryAPI, + federationSenderDB, rsQueryAPI, ) if err = rsConsumer.Start(); err != nil { logrus.WithError(err).Panic("failed to start room server consumer") @@ -52,4 +56,11 @@ func SetupFederationSenderComponent( if err := tsConsumer.Start(); err != nil { logrus.WithError(err).Panic("failed to start typing server consumer") } + + queryAPI := query.FederationSenderQueryAPI{ + DB: federationSenderDB, + } + queryAPI.SetupHTTP(http.DefaultServeMux) + + return &queryAPI } diff --git a/federationsender/query/query.go b/federationsender/query/query.go new file mode 100644 index 000000000..088244826 --- /dev/null +++ b/federationsender/query/query.go @@ -0,0 +1,91 @@ +package query + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/matrix-org/dendrite/common" + "github.com/matrix-org/dendrite/federationsender/api" + "github.com/matrix-org/dendrite/federationsender/types" + "github.com/matrix-org/gomatrixserverlib" + "github.com/matrix-org/util" +) + +// FederationSenderQueryDatabase has the APIs needed to implement the query API. +type FederationSenderQueryDatabase interface { + GetJoinedHosts( + ctx context.Context, roomID string, + ) ([]types.JoinedHost, error) +} + +// FederationSenderQueryAPI is an implementation of api.FederationSenderQueryAPI +type FederationSenderQueryAPI struct { + DB FederationSenderQueryDatabase +} + +// QueryJoinedHostsInRoom implements api.FederationSenderQueryAPI +func (f *FederationSenderQueryAPI) QueryJoinedHostsInRoom( + ctx context.Context, + request *api.QueryJoinedHostsInRoomRequest, + response *api.QueryJoinedHostsInRoomResponse, +) (err error) { + response.JoinedHosts, err = f.DB.GetJoinedHosts(ctx, request.RoomID) + return +} + +// QueryJoinedHostServerNamesInRoom implements api.FederationSenderQueryAPI +func (f *FederationSenderQueryAPI) QueryJoinedHostServerNamesInRoom( + ctx context.Context, + request *api.QueryJoinedHostServerNamesInRoomRequest, + response *api.QueryJoinedHostServerNamesInRoomResponse, +) (err error) { + joinedHosts, err := f.DB.GetJoinedHosts(ctx, request.RoomID) + if err != nil { + return + } + + serverNamesSet := make(map[gomatrixserverlib.ServerName]bool, len(joinedHosts)) + for _, host := range joinedHosts { + serverNamesSet[host.ServerName] = true + } + + response.ServerNames = make([]gomatrixserverlib.ServerName, 0, len(serverNamesSet)) + for name := range serverNamesSet { + response.ServerNames = append(response.ServerNames, name) + } + + return +} + +// SetupHTTP adds the FederationSenderQueryAPI handlers to the http.ServeMux. +func (f *FederationSenderQueryAPI) SetupHTTP(servMux *http.ServeMux) { + servMux.Handle( + api.FederationSenderQueryJoinedHostsInRoomPath, + common.MakeInternalAPI("QueryJoinedHostsInRoom", func(req *http.Request) util.JSONResponse { + var request api.QueryJoinedHostsInRoomRequest + var response api.QueryJoinedHostsInRoomResponse + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.ErrorResponse(err) + } + if err := f.QueryJoinedHostsInRoom(req.Context(), &request, &response); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &response} + }), + ) + servMux.Handle( + api.FederationSenderQueryJoinedHostServerNamesInRoomPath, + common.MakeInternalAPI("QueryJoinedHostServerNamesInRoom", func(req *http.Request) util.JSONResponse { + var request api.QueryJoinedHostServerNamesInRoomRequest + var response api.QueryJoinedHostServerNamesInRoomResponse + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.ErrorResponse(err) + } + if err := f.QueryJoinedHostServerNamesInRoom(req.Context(), &request, &response); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &response} + }), + ) +} diff --git a/roomserver/api/input.go b/roomserver/api/input.go index 2c2e27c62..9643a927c 100644 --- a/roomserver/api/input.go +++ b/roomserver/api/input.go @@ -75,9 +75,9 @@ type InputRoomEvent struct { } // TransactionID contains the transaction ID sent by a client when sending an -// event, along with the ID of that device. +// event, along with the ID of the client session. type TransactionID struct { - DeviceID string `json:"device_id"` + SessionID int64 `json:"session_id"` TransactionID string `json:"id"` } diff --git a/roomserver/input/events.go b/roomserver/input/events.go index feb15b3e1..b30c39928 100644 --- a/roomserver/input/events.go +++ b/roomserver/input/events.go @@ -32,7 +32,7 @@ type RoomEventDatabase interface { StoreEvent( ctx context.Context, event gomatrixserverlib.Event, - txnAndDeviceID *api.TransactionID, + txnAndSessionID *api.TransactionID, authEventNIDs []types.EventNID, ) (types.RoomNID, types.StateAtEvent, error) // Look up the state entries for a list of string event IDs @@ -67,7 +67,7 @@ type RoomEventDatabase interface { // Returns an empty string if no such event exists. GetTransactionEventID( ctx context.Context, transactionID string, - deviceID string, userID string, + sessionID int64, userID string, ) (string, error) } @@ -100,7 +100,7 @@ func processRoomEvent( if input.TransactionID != nil { tdID := input.TransactionID eventID, err = db.GetTransactionEventID( - ctx, tdID.TransactionID, tdID.DeviceID, input.Event.Sender(), + ctx, tdID.TransactionID, tdID.SessionID, input.Event.Sender(), ) // On error OR event with the transaction already processed/processesing if err != nil || eventID != "" { diff --git a/roomserver/storage/storage.go b/roomserver/storage/storage.go index 71c13b7ca..7e8eb98c9 100644 --- a/roomserver/storage/storage.go +++ b/roomserver/storage/storage.go @@ -47,7 +47,7 @@ func Open(dataSourceName string) (*Database, error) { // StoreEvent implements input.EventDatabase func (d *Database) StoreEvent( ctx context.Context, event gomatrixserverlib.Event, - txnAndDeviceID *api.TransactionID, authEventNIDs []types.EventNID, + txnAndSessionID *api.TransactionID, authEventNIDs []types.EventNID, ) (types.RoomNID, types.StateAtEvent, error) { var ( roomNID types.RoomNID @@ -58,10 +58,10 @@ func (d *Database) StoreEvent( err error ) - if txnAndDeviceID != nil { + if txnAndSessionID != nil { if err = d.statements.insertTransaction( - ctx, txnAndDeviceID.TransactionID, - txnAndDeviceID.DeviceID, event.Sender(), event.EventID(), + ctx, txnAndSessionID.TransactionID, + txnAndSessionID.SessionID, event.Sender(), event.EventID(), ); err != nil { return 0, types.StateAtEvent{}, err } @@ -322,9 +322,9 @@ func (d *Database) GetLatestEventsForUpdate( // GetTransactionEventID implements input.EventDatabase func (d *Database) GetTransactionEventID( ctx context.Context, transactionID string, - deviceID string, userID string, + sessionID int64, userID string, ) (string, error) { - eventID, err := d.statements.selectTransactionEventID(ctx, transactionID, deviceID, userID) + eventID, err := d.statements.selectTransactionEventID(ctx, transactionID, sessionID, userID) if err == sql.ErrNoRows { return "", nil } diff --git a/roomserver/storage/transactions_table.go b/roomserver/storage/transactions_table.go index e9c904cc8..b98ea3f33 100644 --- a/roomserver/storage/transactions_table.go +++ b/roomserver/storage/transactions_table.go @@ -23,8 +23,8 @@ const transactionsSchema = ` CREATE TABLE IF NOT EXISTS roomserver_transactions ( -- The transaction ID of the event. transaction_id TEXT NOT NULL, - -- The device ID of the originating transaction. - device_id TEXT NOT NULL, + -- The session ID of the originating transaction. + session_id BIGINT NOT NULL, -- User ID of the sender who authored the event user_id TEXT NOT NULL, -- Event ID corresponding to the transaction @@ -32,16 +32,16 @@ CREATE TABLE IF NOT EXISTS roomserver_transactions ( event_id TEXT NOT NULL, -- A transaction ID is unique for a user and device -- This automatically creates an index. - PRIMARY KEY (transaction_id, device_id, user_id) + PRIMARY KEY (transaction_id, session_id, user_id) ); ` const insertTransactionSQL = "" + - "INSERT INTO roomserver_transactions (transaction_id, device_id, user_id, event_id)" + + "INSERT INTO roomserver_transactions (transaction_id, session_id, user_id, event_id)" + " VALUES ($1, $2, $3, $4)" const selectTransactionEventIDSQL = "" + "SELECT event_id FROM roomserver_transactions" + - " WHERE transaction_id = $1 AND device_id = $2 AND user_id = $3" + " WHERE transaction_id = $1 AND session_id = $2 AND user_id = $3" type transactionStatements struct { insertTransactionStmt *sql.Stmt @@ -63,12 +63,12 @@ func (s *transactionStatements) prepare(db *sql.DB) (err error) { func (s *transactionStatements) insertTransaction( ctx context.Context, transactionID string, - deviceID string, + sessionID int64, userID string, eventID string, ) (err error) { _, err = s.insertTransactionStmt.ExecContext( - ctx, transactionID, deviceID, userID, eventID, + ctx, transactionID, sessionID, userID, eventID, ) return } @@ -76,11 +76,11 @@ func (s *transactionStatements) insertTransaction( func (s *transactionStatements) selectTransactionEventID( ctx context.Context, transactionID string, - deviceID string, + sessionID int64, userID string, ) (eventID string, err error) { err = s.selectTransactionEventIDStmt.QueryRowContext( - ctx, transactionID, deviceID, userID, + ctx, transactionID, sessionID, userID, ).Scan(&eventID) return } diff --git a/syncapi/storage/output_room_events_table.go b/syncapi/storage/output_room_events_table.go index d4db04eb6..eb833e783 100644 --- a/syncapi/storage/output_room_events_table.go +++ b/syncapi/storage/output_room_events_table.go @@ -54,7 +54,7 @@ CREATE TABLE IF NOT EXISTS syncapi_output_room_events ( -- if there is no delta. add_state_ids TEXT[], remove_state_ids TEXT[], - device_id TEXT, -- The local device that sent the event, if any + session_id BIGINT, -- The client session that sent the event, if any transaction_id TEXT -- The transaction id used to send the event, if any ); -- for event selection @@ -63,15 +63,15 @@ CREATE UNIQUE INDEX IF NOT EXISTS syncapi_event_id_idx ON syncapi_output_room_ev const insertEventSQL = "" + "INSERT INTO syncapi_output_room_events (" + - "room_id, event_id, event_json, type, sender, contains_url, add_state_ids, remove_state_ids, device_id, transaction_id" + + "room_id, event_id, event_json, type, sender, contains_url, add_state_ids, remove_state_ids, session_id, transaction_id" + ") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id" const selectEventsSQL = "" + - "SELECT id, event_json, device_id, transaction_id" + + "SELECT id, event_json, session_id, transaction_id" + " FROM syncapi_output_room_events WHERE event_id = ANY($1)" const selectRecentEventsSQL = "" + - "SELECT id, event_json, device_id, transaction_id" + + "SELECT id, event_json, session_id, transaction_id" + " FROM syncapi_output_room_events" + " WHERE room_id = $1 AND id > $2 AND id <= $3" + " ORDER BY id DESC LIMIT $4" @@ -223,9 +223,10 @@ func (s *outputRoomEventsStatements) insertEvent( event *gomatrixserverlib.Event, addState, removeState []string, transactionID *api.TransactionID, ) (streamPos int64, err error) { - var deviceID, txnID *string + var txnID *string + var sessionID *int64 if transactionID != nil { - deviceID = &transactionID.DeviceID + sessionID = &transactionID.SessionID txnID = &transactionID.TransactionID } @@ -248,7 +249,7 @@ func (s *outputRoomEventsStatements) insertEvent( containsURL, pq.StringArray(addState), pq.StringArray(removeState), - deviceID, + sessionID, txnID, ).Scan(&streamPos) return @@ -298,11 +299,11 @@ func rowsToStreamEvents(rows *sql.Rows) ([]streamEvent, error) { var ( streamPos int64 eventBytes []byte - deviceID *string + sessionID *int64 txnID *string transactionID *api.TransactionID ) - if err := rows.Scan(&streamPos, &eventBytes, &deviceID, &txnID); err != nil { + if err := rows.Scan(&streamPos, &eventBytes, &sessionID, &txnID); err != nil { return nil, err } // TODO: Handle redacted events @@ -311,9 +312,9 @@ func rowsToStreamEvents(rows *sql.Rows) ([]streamEvent, error) { return nil, err } - if deviceID != nil && txnID != nil { + if sessionID != nil && txnID != nil { transactionID = &api.TransactionID{ - DeviceID: *deviceID, + SessionID: *sessionID, TransactionID: *txnID, } } diff --git a/syncapi/storage/syncserver.go b/syncapi/storage/syncserver.go index 9a0b29821..57439ce0a 100644 --- a/syncapi/storage/syncserver.go +++ b/syncapi/storage/syncserver.go @@ -893,7 +893,7 @@ func streamEventsToEvents(device *authtypes.Device, in []streamEvent) []gomatrix for i := 0; i < len(in); i++ { out[i] = in[i].Event if device != nil && in[i].transactionID != nil { - if device.UserID == in[i].Sender() && device.ID == in[i].transactionID.DeviceID { + if device.UserID == in[i].Sender() && device.SessionID == in[i].transactionID.SessionID { err := out[i].SetUnsignedField( "transaction_id", in[i].transactionID.TransactionID, )