Repository: hyperledger/firefly
Pull request #1118 (2022-12-15 16:20:25)

Add messages to token approvals
Signed-off-by: Nicko Guyer <nicko.guyer@kaleido.io>


Patch:
diff --git a/db/migrations/postgres/000102_alter_tokenapproval_add_message.down.sql b/db/migrations/postgres/000102_alter_tokenapproval_add_message.down.sql
new file mode 100644
index 000000000..077464720
--- /dev/null
+++ b/db/migrations/postgres/000102_alter_tokenapproval_add_message.down.sql
@@ -0,0 +1,7 @@
+BEGIN;
+
+DROP INDEX tokenapproval_messageid;
+ALTER TABLE tokenapproval DROP COLUMN message_id;
+ALTER TABLE tokenapproval DROP COLUMN message_hash;
+
+COMMIT;
diff --git a/db/migrations/postgres/000102_alter_tokenapproval_add_message.up.sql b/db/migrations/postgres/000102_alter_tokenapproval_add_message.up.sql
new file mode 100644
index 000000000..e214fbf56
--- /dev/null
+++ b/db/migrations/postgres/000102_alter_tokenapproval_add_message.up.sql
@@ -0,0 +1,7 @@
+BEGIN;
+
+ALTER TABLE tokenapproval ADD COLUMN message_id UUID;
+ALTER TABLE tokenapproval ADD COLUMN message_hash CHAR(64);
+CREATE INDEX tokenapproval_messageid ON tokenapproval(message_id);
+
+COMMIT;
diff --git a/db/migrations/sqlite/000102_alter_tokenapproval_add_message.down.sql b/db/migrations/sqlite/000102_alter_tokenapproval_add_message.down.sql
new file mode 100644
index 000000000..9e9b33bce
--- /dev/null
+++ b/db/migrations/sqlite/000102_alter_tokenapproval_add_message.down.sql
@@ -0,0 +1,3 @@
+DROP INDEX tokenapproval_messageid;
+ALTER TABLE tokenapproval DROP COLUMN message_id;
+ALTER TABLE tokenapproval DROP COLUMN message_hash;
\ No newline at end of file
diff --git a/db/migrations/sqlite/000102_alter_tokenapproval_add_message.up.sql b/db/migrations/sqlite/000102_alter_tokenapproval_add_message.up.sql
new file mode 100644
index 000000000..5d82afb7d
--- /dev/null
+++ b/db/migrations/sqlite/000102_alter_tokenapproval_add_message.up.sql
@@ -0,0 +1,3 @@
+ALTER TABLE tokenapproval ADD COLUMN message_id UUID;
+ALTER TABLE tokenapproval ADD COLUMN message_hash CHAR(64);
+CREATE INDEX tokenapproval_messageid ON tokenapproval(message_id);
\ No newline at end of file
diff --git a/docs/reference/types/message.md b/docs/reference/types/message.md
index 791507a1b..c64a3b3cf 100644
--- a/docs/reference/types/message.md
+++ b/docs/reference/types/message.md
@@ -72,7 +72,7 @@ nav_order: 15
 |------------|-------------|------|
 | `id` | The UUID of the message. Unique to each message | [`UUID`](simpletypes#uuid) |
 | `cid` | The correlation ID of the message. Set this when a message is a response to another message | [`UUID`](simpletypes#uuid) |
-| `type` | The type of the message | `FFEnum`:<br/>`"definition"`<br/>`"broadcast"`<br/>`"private"`<br/>`"groupinit"`<br/>`"transfer_broadcast"`<br/>`"transfer_private"` |
+| `type` | The type of the message | `FFEnum`:<br/>`"definition"`<br/>`"broadcast"`<br/>`"private"`<br/>`"groupinit"`<br/>`"transfer_broadcast"`<br/>`"transfer_private"`<br/>`"approval_broadcast"`<br/>`"approval_private"` |
 | `txtype` | The type of transaction used to order/deliver this message | `FFEnum`:<br/>`"none"`<br/>`"unpinned"`<br/>`"batch_pin"`<br/>`"network_action"`<br/>`"token_pool"`<br/>`"token_transfer"`<br/>`"contract_deploy"`<br/>`"contract_invoke"`<br/>`"token_approval"`<br/>`"data_publish"` |
 | `author` | The DID of identity of the submitter | `string` |
 | `key` | The on-chain signing key used to sign the transaction | `string` |
diff --git a/docs/reference/types/tokenapproval.md b/docs/reference/types/tokenapproval.md
index 39c9d1fcf..5919a5b5a 100644
--- a/docs/reference/types/tokenapproval.md
+++ b/docs/reference/types/tokenapproval.md
@@ -62,6 +62,8 @@ nav_order: 12
 | `protocolId` | An alphanumerically sortable string that represents this event uniquely with respect to the blockchain | `string` |
 | `subject` | A string identifying the parties and entities in the scope of this approval, as provided by the token connector | `string` |
 | `active` | Indicates if this approval is currently active (only one approval can be active per subject) | `bool` |
+| `message` | The UUID of a message that has been correlated with this approval using the data field of the approval in a compatible token connector | [`UUID`](simpletypes#uuid) |
+| `messageHash` | The hash of a message that has been correlated with this approval using the data field of the approval in a compatible token connector | `Bytes32` |
 | `created` | The creation time of the token approval | [`FFTime`](simpletypes#fftime) |
 | `tx` | If submitted via FireFly, this will reference the UUID of the FireFly transaction (if the token connector in use supports attaching data) | [`TransactionRef`](#transactionref) |
 | `blockchainEvent` | The UUID of the blockchain event | [`UUID`](simpletypes#uuid) |
diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml
index 6a4997713..bcf5d5d5d 100644
--- a/docs/swagger/swagger.yaml
+++ b/docs/swagger/swagger.yaml
@@ -5251,6 +5251,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -7688,6 +7690,8 @@ paths:
                           - groupinit
                           - transfer_broadcast
                           - transfer_private
+                          - approval_broadcast
+                          - approval_private
                           type: string
                       type: object
                     idempotencyKey:
@@ -7938,6 +7942,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -8431,6 +8437,8 @@ paths:
                       - groupinit
                       - transfer_broadcast
                       - transfer_private
+                      - approval_broadcast
+                      - approval_private
                       type: string
                   type: object
                 idempotencyKey:
@@ -8545,6 +8553,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -8680,6 +8690,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -8863,6 +8875,8 @@ paths:
                       - groupinit
                       - transfer_broadcast
                       - transfer_private
+                      - approval_broadcast
+                      - approval_private
                       type: string
                   type: object
                 idempotencyKey:
@@ -8983,6 +8997,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -9124,6 +9140,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -9303,6 +9321,8 @@ paths:
                       - groupinit
                       - transfer_broadcast
                       - transfer_private
+                      - approval_broadcast
+                      - approval_private
                       type: string
                   type: object
                 idempotencyKey:
@@ -9503,6 +9523,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -15280,6 +15302,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -17836,6 +17860,8 @@ paths:
                           - groupinit
                           - transfer_broadcast
                           - transfer_private
+                          - approval_broadcast
+                          - approval_private
                           type: string
                       type: object
                     idempotencyKey:
@@ -18093,6 +18119,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -18652,6 +18680,8 @@ paths:
                       - groupinit
                       - transfer_broadcast
                       - transfer_private
+                      - approval_broadcast
+                      - approval_private
                       type: string
                   type: object
                 idempotencyKey:
@@ -18772,6 +18802,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -18913,6 +18945,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -19103,6 +19137,8 @@ paths:
                       - groupinit
                       - transfer_broadcast
                       - transfer_private
+                      - approval_broadcast
+                      - approval_private
                       type: string
                   type: object
                 idempotencyKey:
@@ -19223,6 +19259,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -19364,6 +19402,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -19550,6 +19590,8 @@ paths:
                       - groupinit
                       - transfer_broadcast
                       - transfer_private
+                      - approval_broadcast
+                      - approval_private
                       type: string
                   type: object
                 idempotencyKey:
@@ -19750,6 +19792,8 @@ paths:
                         - groupinit
                         - transfer_broadcast
                         - transfer_private
+                        - approval_broadcast
+                        - approval_private
                         type: string
                     type: object
                   idempotencyKey:
@@ -24109,6 +24153,16 @@ paths:
         name: localid
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: message
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: messagehash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: operator
@@ -24223,6 +24277,18 @@ paths:
                         node
                       format: uuid
                       type: string
+                    message:
+                      description: The UUID of a message that has been correlated
+                        with this approval using the data field of the approval in
+                        a compatible token connector
+                      format: uuid
+                      type: string
+                    messageHash:
+                      description: The hash of a message that has been correlated
+                        with this approval using the data field of the approval in
+                        a compatible token connector
+                      format: byte
+                      type: string
                     namespace:
                       description: The namespace for the approval, which must match
                         the namespace of the token pool
@@ -24313,6 +24379,12 @@ paths:
                     On input defaults to the first signing key of the organization
                     that operates the node
                   type: string
+                message:
+                  description: The UUID of a message that has been correlated with
+                    this approval using the data field of the approval in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 operator:
                   description: The blockchain identity that is granted the approval
                   type: string
@@ -24370,6 +24442,18 @@ paths:
                       node
                     format: uuid
                     type: string
+                  message:
+                    description: The UUID of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: uuid
+                    type: string
+                  messageHash:
+                    description: The hash of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: byte
+                    type: string
                   namespace:
                     description: The namespace for the approval, which must match
                       the namespace of the token pool
@@ -24452,6 +24536,18 @@ paths:
                       node
                     format: uuid
                     type: string
+                  message:
+                    description: The UUID of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: uuid
+                    type: string
+                  messageHash:
+                    description: The hash of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: byte
+                    type: string
                   namespace:
                     description: The namespace for the approval, which must match
                       the namespace of the token pool
@@ -31398,6 +31494,16 @@ paths:
         name: localid
         schema:
           type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: message
+        schema:
+          type: string
+      - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
+        in: query
+        name: messagehash
+        schema:
+          type: string
       - description: 'Data filter field. Prefixes supported: > >= < <= @ ^ ! !@ !^'
         in: query
         name: operator
@@ -31512,6 +31618,18 @@ paths:
                         node
                       format: uuid
                       type: string
+                    message:
+                      description: The UUID of a message that has been correlated
+                        with this approval using the data field of the approval in
+                        a compatible token connector
+                      format: uuid
+                      type: string
+                    messageHash:
+                      description: The hash of a message that has been correlated
+                        with this approval using the data field of the approval in
+                        a compatible token connector
+                      format: byte
+                      type: string
                     namespace:
                       description: The namespace for the approval, which must match
                         the namespace of the token pool
@@ -31595,6 +31713,12 @@ paths:
                     On input defaults to the first signing key of the organization
                     that operates the node
                   type: string
+                message:
+                  description: The UUID of a message that has been correlated with
+                    this approval using the data field of the approval in a compatible
+                    token connector
+                  format: uuid
+                  type: string
                 operator:
                   description: The blockchain identity that is granted the approval
                   type: string
@@ -31652,6 +31776,18 @@ paths:
                       node
                     format: uuid
                     type: string
+                  message:
+                    description: The UUID of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: uuid
+                    type: string
+                  messageHash:
+                    description: The hash of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: byte
+                    type: string
                   namespace:
                     description: The namespace for the approval, which must match
                       the namespace of the token pool
@@ -31734,6 +31870,18 @@ paths:
                       node
                     format: uuid
                     type: string
+                  message:
+                    description: The UUID of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: uuid
+                    type: string
+                  messageHash:
+                    description: The hash of a message that has been correlated with
+                      this approval using the data field of the approval in a compatible
+                      token connector
+                    format: byte
+                    type: string
                   namespace:
                     description: The namespace for the approval, which must match
                       the namespace of the token pool
diff --git a/internal/assets/token_approval.go b/internal/assets/token_approval.go
index e05a3e40a..28ec45d73 100644
--- a/internal/assets/token_approval.go
+++ b/internal/assets/token_approval.go
@@ -33,20 +33,22 @@ func (am *assetManager) GetTokenApprovals(ctx context.Context, filter ffapi.AndF
 }
 
 type approveSender struct {
-	mgr      *assetManager
-	approval *core.TokenApprovalInput
+	mgr       *assetManager
+	approval  *core.TokenApprovalInput
+	resolved  bool
+	msgSender syncasync.Sender
 }
 
 func (s *approveSender) Prepare(ctx context.Context) error {
-	return s.sendInternal(ctx, methodPrepare)
+	return s.resolveAndSend(ctx, methodPrepare)
 }
 
 func (s *approveSender) Send(ctx context.Context) error {
-	return s.sendInternal(ctx, methodSend)
+	return s.resolveAndSend(ctx, methodSend)
 }
 
 func (s *approveSender) SendAndWait(ctx context.Context) error {
-	return s.sendInternal(ctx, methodSendAndWait)
+	return s.resolveAndSend(ctx, methodSendAndWait)
 }
 
 func (s *approveSender) setDefaults() {
@@ -72,6 +74,42 @@ func (am *assetManager) TokenApproval(ctx context.Context, approval *core.TokenA
 	return &approval.TokenApproval, err
 }
 
+func (s *approveSender) resolveAndSend(ctx context.Context, method sendMethod) (err error) {
+	if !s.resolved {
+		if err = s.resolve(ctx); err != nil {
+			return err
+		}
+		s.resolved = true
+	}
+
+	if method == methodSendAndWait && s.approval.Message != nil {
+		// Begin waiting for the message, and trigger the approval.
+		// A successful approval will trigger the message via the event handler, so we can wait for it all to complete.
+		_, err := s.mgr.syncasync.WaitForMessage(ctx, s.approval.Message.Header.ID, func(ctx context.Context) error {
+			return s.sendInternal(ctx, methodSendAndWait)
+		})
+		return err
+	}
+
+	return s.sendInternal(ctx, method)
+}
+
+func (s *approveSender) resolve(ctx context.Context) (err error) {
+	// Resolve the attached message
+	if s.approval.Message != nil {
+		s.msgSender, err = s.buildApprovalMessage(ctx, s.approval.Message)
+		if err != nil {
+			return err
+		}
+		if err = s.msgSender.Prepare(ctx); err != nil {
+			return err
+		}
+		s.approval.TokenApproval.Message = s.approval.Message.Header.ID
+		s.approval.TokenApproval.MessageHash = s.approval.Message.Hash
+	}
+	return nil
+}
+
 func (s *approveSender) sendInternal(ctx context.Context, method sendMethod) (err error) {
 	if method == methodSendAndWait {
 		out, err := s.mgr.syncasync.WaitForTokenApproval(ctx, s.approval.LocalID, s.Send)
@@ -121,6 +159,14 @@ func (s *approveSender) sendInternal(ctx context.Context, method sendMethod) (er
 		return nil
 	}
 
+	// Write the approval message outside of any DB transaction, as it will use the background message writer.
+	if s.approval.Message != nil {
+		s.approval.Message.State = core.MessageStateStaged
+		if err = s.msgSender.Send(ctx); err != nil {
+			return err
+		}
+	}
+
 	_, err = s.mgr.operations.RunOperation(ctx, opApproval(op, pool, &s.approval.TokenApproval))
 	return err
 }
@@ -146,3 +192,27 @@ func (am *assetManager) validateApproval(ctx context.Context, approval *core.Tok
 	approval.Key, err = am.identity.NormalizeSigningKey(ctx, approval.Key, am.keyNormalization)
 	return pool, err
 }
+
+func (s *approveSender) buildApprovalMessage(ctx context.Context, in *core.MessageInOut) (syncasync.Sender, error) {
+	allowedTypes := []fftypes.FFEnum{
+		core.MessageTypeApprovalBroadcast,
+		core.MessageTypeApprovalPrivate,
+	}
+	if in.Header.Type == "" {
+		in.Header.Type = core.MessageTypeApprovalBroadcast
+	}
+	switch in.Header.Type {
+	case core.MessageTypeApprovalBroadcast:
+		if s.mgr.broadcast == nil {
+			return nil, i18n.NewError(ctx, coremsgs.MsgMessagesNotSupported)
+		}
+		return s.mgr.broadcast.NewBroadcast(in), nil
+	case core.MessageTypeApprovalPrivate:
+		if s.mgr.messaging == nil {
+			return nil, i18n.NewError(ctx, coremsgs.MsgMessagesNotSupported)
+		}
+		return s.mgr.messaging.NewMessage(in), nil
+	default:
+		return nil, i18n.NewError(ctx, coremsgs.MsgInvalidMessageType, allowedTypes)
+	}
+}
diff --git a/internal/broadcast/manager.go b/internal/broadcast/manager.go
index 6d68cd492..702157513 100644
--- a/internal/broadcast/manager.go
+++ b/internal/broadcast/manager.go
@@ -110,6 +110,7 @@ func NewBroadcastManager(ctx context.Context, ns *core.Namespace, di database.Pl
 				core.MessageTypeBroadcast,
 				core.MessageTypeDefinition,
 				core.MessageTypeTransferBroadcast,
+				core.MessageTypeApprovalBroadcast,
 			}, bm.dispatchBatch, bo)
 	}
 
diff --git a/internal/coremsgs/en_struct_descriptions.go b/internal/coremsgs/en_struct_descriptions.go
index f9d3369e4..ebc6111c8 100644
--- a/internal/coremsgs/en_struct_descriptions.go
+++ b/internal/coremsgs/en_struct_descriptions.go
@@ -519,12 +519,15 @@ var (
 	TokenApprovalProtocolID      = ffm("TokenApproval.protocolId", "An alphanumerically sortable string that represents this event uniquely with respect to the blockchain")
 	TokenApprovalSubject         = ffm("TokenApproval.subject", "A string identifying the parties and entities in the scope of this approval, as provided by the token connector")
 	TokenApprovalActive          = ffm("TokenApproval.active", "Indicates if this approval is currently active (only one approval can be active per subject)")
+	TokenApprovalMessage         = ffm("TokenApproval.message", "The UUID of a message that has been correlated with this approval using the data field of the approval in a compatible token connector")
+	TokenApprovalMessageHash     = ffm("TokenApproval.messageHash", "The hash of a message that has been correlated with this approval using the data field of the approval in a compatible token connector")
 	TokenApprovalCreated         = ffm("TokenApproval.created", "The creation time of the token approval")
 	TokenApprovalTX              = ffm("TokenApproval.tx", "If submitted via FireFly, this will reference the UUID of the FireFly transaction (if the token connector in use supports attaching data)")
 	TokenApprovalBlockchainEvent = ffm("TokenApproval.blockchainEvent", "The UUID of the blockchain event")
 	TokenApprovalConfig          = ffm("TokenApproval.config", "Input only field, with token connector specific configuration of the approval.  See your chosen token connector documentation for details")
 
 	// TokenApprovalInput field descriptions
+	TokenApprovalInputMessage        = ffm("TokenApprovalInput.message", "You can specify a message to correlate with the approval, which can be of type broadcast or private. Your chosen token connector and on-chain smart contract must support on-chain/off-chain correlation by taking a `data` input on the approval")
 	TokenApprovalInputPool           = ffm("TokenApprovalInput.pool", "The name or UUID of a token pool. Required if more than one pool exists.")
 	TokenApprovalInputIdempotencyKey = ffm("TokenApprovalInput.idempotencyKey", "An optional identifier to allow idempotent submission of requests. Stored on the transaction uniquely within a namespace")
 
diff --git a/internal/database/sqlcommon/tokenapproval_sql.go b/internal/database/sqlcommon/tokenapproval_sql.go
index 87c2e73ec..6893ea18b 100644
--- a/internal/database/sqlcommon/tokenapproval_sql.go
+++ b/internal/database/sqlcommon/tokenapproval_sql.go
@@ -47,6 +47,8 @@ var (
 		"tx_id",
 		"blockchain_event",
 		"created",
+		"message_id",
+		"message_hash",
 	}
 	tokenApprovalFilterFieldMap = map[string]string{
 		"localid":         "local_id",
@@ -59,6 +61,8 @@ var (
 		"tx.id":           "tx_id",
 		"blockchainevent": "blockchain_event",
 		"created":         "created",
+		"message":         "message_id",
+		"messagehash":     "message_hash",
 	}
 )
 
@@ -100,6 +104,8 @@ func (s *SQLCommon) UpsertTokenApproval(ctx context.Context, approval *core.Toke
 				Set("tx_type", approval.TX.Type).
 				Set("tx_id", approval.TX.ID).
 				Set("blockchain_event", approval.BlockchainEvent).
+				Set("message_id", approval.Message).
+				Set("message_hash", approval.MessageHash).
 				Where(sq.Eq{"protocol_id": approval.ProtocolID}),
 			func() {
 				s.callbacks.UUIDCollectionNSEvent(database.CollectionTokenApprovals, core.ChangeEventTypeUpdated, approval.Namespace, approval.LocalID)
@@ -128,6 +134,8 @@ func (s *SQLCommon) UpsertTokenApproval(ctx context.Context, approval *core.Toke
 					approval.TX.ID,
 					approval.BlockchainEvent,
 					approval.Created,
+					approval.Message,
+					approval.MessageHash,
 				),
 			func() {
 				s.callbacks.UUIDCollectionNSEvent(database.CollectionTokenApprovals, core.ChangeEventTypeCreated, approval.Namespace, approval.LocalID)
@@ -157,6 +165,8 @@ func (s *SQLCommon) tokenApprovalResult(ctx context.Context, row *sql.Rows) (*co
 		&approval.TX.ID,
 		&approval.BlockchainEvent,
 		&approval.Created,
+		&approval.Message,
+		&approval.MessageHash,
 	)
 	if err != nil {
 		return nil, i18n.WrapError(ctx, err, coremsgs.MsgDBReadErr, tokenapprovalTable)
diff --git a/internal/events/aggregator.go b/internal/events/aggregator.go
index b1e51ed96..2a46c1078 100644
--- a/internal/events/aggregator.go
+++ b/internal/events/aggregator.go
@@ -562,6 +562,21 @@ func (ag *aggregator) attemptMessageDispatch(ctx context.Context, msg *core.Mess
 			}
 		}
 
+		// For approvals, verify the approval has come through
+		if msg.Header.Type == core.MessageTypeApprovalBroadcast || msg.Header.Type == core.MessageTypeApprovalPrivate {
+			fb := database.TokenApprovalQueryFactory.NewFilter(ctx)
+			filter := fb.And(
+				fb.Eq("message", msg.Header.ID),
+			)
+			if approvals, _, err := ag.database.GetTokenApprovals(ctx, ag.namespace, filter); err != nil || len(approvals) == 0 {
+				log.L(ctx).Debugf("Approval for message %s not yet available", msg.Header.ID)
+				return "", false, err
+			} else if !msg.Hash.Equals(approvals[0].MessageHash) {
+				log.L(ctx).Errorf("Message hash %s does not match hash recorded in approval: %s", msg.Hash, approvals[0].MessageHash)
+				return "", false, nil
+			}
+		}
+
 		// Validate the message data
 		switch {
 		case msg.Header.Type == core.MessageTypeDefinition:
diff --git a/internal/events/tokens_approved.go b/internal/events/tokens_approved.go
index 40bf493e2..b9ca4621c 100644
--- a/internal/events/tokens_approved.go
+++ b/internal/events/tokens_approved.go
@@ -133,17 +133,42 @@ func (em *eventManager) persistTokenApproval(ctx context.Context, approval *toke
 }
 
 func (em *eventManager) TokensApproved(ti tokens.Plugin, approval *tokens.TokenApproval) error {
+	var msgIDforRewind *fftypes.UUID
+
 	err := em.retry.Do(em.ctx, "persist token approval", func(attempt int) (bool, error) {
 		err := em.database.RunAsGroup(em.ctx, func(ctx context.Context) error {
 			if valid, err := em.persistTokenApproval(ctx, approval); !valid || err != nil {
 				return err
 			}
 
+			if approval.Message != nil {
+				msg, err := em.database.GetMessageByID(ctx, em.namespace.Name, approval.Message)
+				switch {
+				case err != nil:
+					return err
+				case msg != nil && msg.State == core.MessageStateStaged:
+					// Message can now be sent
+					msg.State = core.MessageStateReady
+					if err := em.database.ReplaceMessage(ctx, msg); err != nil {
+						return err
+					}
+				default:
+					// Message might already have been received, we need to rewind
+					msgIDforRewind = approval.Message
+				}
+			}
+			em.emitBlockchainEventMetric(approval.Event)
+
 			event := core.NewEvent(core.EventTypeApprovalConfirmed, approval.Namespace, approval.LocalID, approval.TX.ID, approval.Pool.String())
 			return em.database.InsertEvent(ctx, event)
 		})
 		return err != nil, err // retry indefinitely (until context closes)
 	})
 
+	// Initiate a rewind if a batch was potentially completed by the arrival of this transfer
+	if err == nil && msgIDforRewind != nil {
+		em.aggregator.queueMessageRewind(msgIDforRewind)
+	}
+
 	return err
 }
diff --git a/internal/privatemessaging/privatemessaging.go b/internal/privatemessaging/privatemessaging.go
index 0e1da0fa5..25b22b2d0 100644
--- a/internal/privatemessaging/privatemessaging.go
+++ b/internal/privatemessaging/privatemessaging.go
@@ -142,6 +142,7 @@ func NewPrivateMessaging(ctx context.Context, ns *core.Namespace, di database.Pl
 			core.MessageTypeGroupInit,
 			core.MessageTypePrivate,
 			core.MessageTypeTransferPrivate,
+			core.MessageTypeApprovalPrivate,
 		},
 		pm.dispatchPinnedBatch, bo)
 
diff --git a/internal/tokens/fftokens/fftokens.go b/internal/tokens/fftokens/fftokens.go
index ed22f1b81..33763d40c 100644
--- a/internal/tokens/fftokens/fftokens.go
+++ b/internal/tokens/fftokens/fftokens.go
@@ -453,13 +453,13 @@ func (ft *FFTokens) handleTokenApproval(ctx context.Context, data fftypes.JSONOb
 	// We want to process all events, even those not initiated by FireFly.
 	// The "data" argument is optional, so it's important not to fail if it's missing or malformed.
 	approvalDataString := data.GetString("data")
-	var transferData tokenData
-	if err = json.Unmarshal([]byte(approvalDataString), &transferData); err != nil {
+	var approvalData tokenData
+	if err = json.Unmarshal([]byte(approvalDataString), &approvalData); err != nil {
 		log.L(ctx).Infof("TokenApproval event data could not be parsed - continuing anyway (%s): %+v", err, data)
-		transferData = tokenData{}
+		approvalData = tokenData{}
 	}
 
-	txType := transferData.TXType
+	txType := approvalData.TXType
 	if txType == "" {
 		txType = core.TransactionTypeTokenApproval
 	}
@@ -467,15 +467,17 @@ func (ft *FFTokens) handleTokenApproval(ctx context.Context, data fftypes.JSONOb
 	approval := &tokens.TokenApproval{
 		PoolLocator: poolLocator,
 		TokenApproval: core.TokenApproval{
-			Connector:  ft.configuredName,
-			Key:        signerAddress,
-			Operator:   operatorAddress,
-			Approved:   approved,
-			ProtocolID: protocolID,
-			Subject:    subject,
-			Info:       info,
+			Connector:   ft.configuredName,
+			Key:         signerAddress,
+			Operator:    operatorAddress,
+			Approved:    approved,
+			ProtocolID:  protocolID,
+			Subject:     subject,
+			Info:        info,
+			Message:     approvalData.Message,
+			MessageHash: approvalData.MessageHash,
 			TX: core.TransactionRef{
-				ID:   transferData.TX,
+				ID:   approvalData.TX,
 				Type: txType,
 			},
 		},
@@ -726,8 +728,10 @@ func (ft *FFTokens) TransferTokens(ctx context.Context, nsOpID string, poolLocat
 
 func (ft *FFTokens) TokensApproval(ctx context.Context, nsOpID string, poolLocator string, approval *core.TokenApproval) error {
 	data, _ := json.Marshal(tokenData{
-		TX:     approval.TX.ID,
-		TXType: approval.TX.Type,
+		TX:          approval.TX.ID,
+		TXType:      approval.TX.Type,
+		Message:     approval.Message,
+		MessageHash: approval.MessageHash,
 	})
 	var errRes tokenError
 	res, err := ft.client.R().SetContext(ctx).
diff --git a/mocks/apiservermocks/ffi_swagger_gen.go b/mocks/apiservermocks/ffi_swagger_gen.go
index 840eafffb..6e0755e19 100644
--- a/mocks/apiservermocks/ffi_swagger_gen.go
+++ b/mocks/apiservermocks/ffi_swagger_gen.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package apiservermocks
 
diff --git a/mocks/apiservermocks/server.go b/mocks/apiservermocks/server.go
index 8e2d7858d..ae9c9e355 100644
--- a/mocks/apiservermocks/server.go
+++ b/mocks/apiservermocks/server.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package apiservermocks
 
diff --git a/mocks/assetmocks/manager.go b/mocks/assetmocks/manager.go
index 230043a4a..d1511398e 100644
--- a/mocks/assetmocks/manager.go
+++ b/mocks/assetmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package assetmocks
 
diff --git a/mocks/batchmocks/manager.go b/mocks/batchmocks/manager.go
index 3e987ac49..70fbdf309 100644
--- a/mocks/batchmocks/manager.go
+++ b/mocks/batchmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package batchmocks
 
diff --git a/mocks/blockchainmocks/callbacks.go b/mocks/blockchainmocks/callbacks.go
index 024268dcd..54e3923ac 100644
--- a/mocks/blockchainmocks/callbacks.go
+++ b/mocks/blockchainmocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package blockchainmocks
 
diff --git a/mocks/blockchainmocks/plugin.go b/mocks/blockchainmocks/plugin.go
index d4606255b..7de499aaa 100644
--- a/mocks/blockchainmocks/plugin.go
+++ b/mocks/blockchainmocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package blockchainmocks
 
diff --git a/mocks/broadcastmocks/manager.go b/mocks/broadcastmocks/manager.go
index 770dc5361..d98c8d7df 100644
--- a/mocks/broadcastmocks/manager.go
+++ b/mocks/broadcastmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package broadcastmocks
 
diff --git a/mocks/cachemocks/manager.go b/mocks/cachemocks/manager.go
index 48d54cd19..f9bdc4bad 100644
--- a/mocks/cachemocks/manager.go
+++ b/mocks/cachemocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package cachemocks
 
diff --git a/mocks/contractmocks/manager.go b/mocks/contractmocks/manager.go
index 298fe7e13..1e86b25a4 100644
--- a/mocks/contractmocks/manager.go
+++ b/mocks/contractmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package contractmocks
 
diff --git a/mocks/coremocks/operation_callbacks.go b/mocks/coremocks/operation_callbacks.go
index 5e0700d0b..049b7d64a 100644
--- a/mocks/coremocks/operation_callbacks.go
+++ b/mocks/coremocks/operation_callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package coremocks
 
diff --git a/mocks/databasemocks/callbacks.go b/mocks/databasemocks/callbacks.go
index 7bf2d3286..07a7d808b 100644
--- a/mocks/databasemocks/callbacks.go
+++ b/mocks/databasemocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package databasemocks
 
diff --git a/mocks/databasemocks/plugin.go b/mocks/databasemocks/plugin.go
index 915bd0af0..dcabf14fd 100644
--- a/mocks/databasemocks/plugin.go
+++ b/mocks/databasemocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package databasemocks
 
diff --git a/mocks/dataexchangemocks/callbacks.go b/mocks/dataexchangemocks/callbacks.go
index 7ecda9c04..b5fa9002e 100644
--- a/mocks/dataexchangemocks/callbacks.go
+++ b/mocks/dataexchangemocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package dataexchangemocks
 
diff --git a/mocks/dataexchangemocks/dx_event.go b/mocks/dataexchangemocks/dx_event.go
index 82dae91e1..1220ec563 100644
--- a/mocks/dataexchangemocks/dx_event.go
+++ b/mocks/dataexchangemocks/dx_event.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package dataexchangemocks
 
diff --git a/mocks/dataexchangemocks/plugin.go b/mocks/dataexchangemocks/plugin.go
index 01672b978..7803ed134 100644
--- a/mocks/dataexchangemocks/plugin.go
+++ b/mocks/dataexchangemocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package dataexchangemocks
 
diff --git a/mocks/datamocks/manager.go b/mocks/datamocks/manager.go
index 4cce3323a..e7d708d5d 100644
--- a/mocks/datamocks/manager.go
+++ b/mocks/datamocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package datamocks
 
diff --git a/mocks/definitionsmocks/handler.go b/mocks/definitionsmocks/handler.go
index 7b1ab52f0..e8895493b 100644
--- a/mocks/definitionsmocks/handler.go
+++ b/mocks/definitionsmocks/handler.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package definitionsmocks
 
diff --git a/mocks/definitionsmocks/sender.go b/mocks/definitionsmocks/sender.go
index 44a207e4d..6a4e06c2f 100644
--- a/mocks/definitionsmocks/sender.go
+++ b/mocks/definitionsmocks/sender.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package definitionsmocks
 
diff --git a/mocks/eventmocks/event_manager.go b/mocks/eventmocks/event_manager.go
index f580c1e6a..f3e19212d 100644
--- a/mocks/eventmocks/event_manager.go
+++ b/mocks/eventmocks/event_manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package eventmocks
 
diff --git a/mocks/eventsmocks/callbacks.go b/mocks/eventsmocks/callbacks.go
index c3e746d3a..d9093fb25 100644
--- a/mocks/eventsmocks/callbacks.go
+++ b/mocks/eventsmocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package eventsmocks
 
diff --git a/mocks/eventsmocks/plugin.go b/mocks/eventsmocks/plugin.go
index 82e66cbab..8093bfd02 100644
--- a/mocks/eventsmocks/plugin.go
+++ b/mocks/eventsmocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package eventsmocks
 
diff --git a/mocks/identitymanagermocks/manager.go b/mocks/identitymanagermocks/manager.go
index db1833a82..4634e0781 100644
--- a/mocks/identitymanagermocks/manager.go
+++ b/mocks/identitymanagermocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package identitymanagermocks
 
diff --git a/mocks/identitymocks/callbacks.go b/mocks/identitymocks/callbacks.go
index 1684659f0..d4cc9a94c 100644
--- a/mocks/identitymocks/callbacks.go
+++ b/mocks/identitymocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package identitymocks
 
diff --git a/mocks/identitymocks/plugin.go b/mocks/identitymocks/plugin.go
index 329ae5f13..1d428704d 100644
--- a/mocks/identitymocks/plugin.go
+++ b/mocks/identitymocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package identitymocks
 
diff --git a/mocks/metricsmocks/manager.go b/mocks/metricsmocks/manager.go
index 17141a831..1b4154aba 100644
--- a/mocks/metricsmocks/manager.go
+++ b/mocks/metricsmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package metricsmocks
 
diff --git a/mocks/multipartymocks/manager.go b/mocks/multipartymocks/manager.go
index 80a3277b8..abeaeba34 100644
--- a/mocks/multipartymocks/manager.go
+++ b/mocks/multipartymocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package multipartymocks
 
diff --git a/mocks/namespacemocks/manager.go b/mocks/namespacemocks/manager.go
index 107c5782d..5b02d258f 100644
--- a/mocks/namespacemocks/manager.go
+++ b/mocks/namespacemocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package namespacemocks
 
diff --git a/mocks/networkmapmocks/manager.go b/mocks/networkmapmocks/manager.go
index 5d9f7dbaf..1baf056bb 100644
--- a/mocks/networkmapmocks/manager.go
+++ b/mocks/networkmapmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package networkmapmocks
 
diff --git a/mocks/operationmocks/manager.go b/mocks/operationmocks/manager.go
index 6fd421a1d..fc80e7ee1 100644
--- a/mocks/operationmocks/manager.go
+++ b/mocks/operationmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package operationmocks
 
diff --git a/mocks/orchestratormocks/orchestrator.go b/mocks/orchestratormocks/orchestrator.go
index af2668652..a7653ad3f 100644
--- a/mocks/orchestratormocks/orchestrator.go
+++ b/mocks/orchestratormocks/orchestrator.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package orchestratormocks
 
diff --git a/mocks/privatemessagingmocks/manager.go b/mocks/privatemessagingmocks/manager.go
index 7400ed42d..e6648913c 100644
--- a/mocks/privatemessagingmocks/manager.go
+++ b/mocks/privatemessagingmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package privatemessagingmocks
 
diff --git a/mocks/shareddownloadmocks/callbacks.go b/mocks/shareddownloadmocks/callbacks.go
index 67f2546cc..833ebddb0 100644
--- a/mocks/shareddownloadmocks/callbacks.go
+++ b/mocks/shareddownloadmocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package shareddownloadmocks
 
diff --git a/mocks/shareddownloadmocks/manager.go b/mocks/shareddownloadmocks/manager.go
index 698246330..d485e580e 100644
--- a/mocks/shareddownloadmocks/manager.go
+++ b/mocks/shareddownloadmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package shareddownloadmocks
 
diff --git a/mocks/sharedstoragemocks/callbacks.go b/mocks/sharedstoragemocks/callbacks.go
index cba3791df..157d31c3f 100644
--- a/mocks/sharedstoragemocks/callbacks.go
+++ b/mocks/sharedstoragemocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package sharedstoragemocks
 
diff --git a/mocks/sharedstoragemocks/plugin.go b/mocks/sharedstoragemocks/plugin.go
index 2c2a49278..5efc84184 100644
--- a/mocks/sharedstoragemocks/plugin.go
+++ b/mocks/sharedstoragemocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package sharedstoragemocks
 
diff --git a/mocks/spieventsmocks/manager.go b/mocks/spieventsmocks/manager.go
index 0ae3113e7..f8f5a5c6b 100644
--- a/mocks/spieventsmocks/manager.go
+++ b/mocks/spieventsmocks/manager.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package spieventsmocks
 
diff --git a/mocks/syncasyncmocks/bridge.go b/mocks/syncasyncmocks/bridge.go
index 2deb4f70c..d92529b87 100644
--- a/mocks/syncasyncmocks/bridge.go
+++ b/mocks/syncasyncmocks/bridge.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package syncasyncmocks
 
diff --git a/mocks/syncasyncmocks/sender.go b/mocks/syncasyncmocks/sender.go
index 97d17f0ca..266453cc7 100644
--- a/mocks/syncasyncmocks/sender.go
+++ b/mocks/syncasyncmocks/sender.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package syncasyncmocks
 
diff --git a/mocks/systemeventmocks/event_interface.go b/mocks/systemeventmocks/event_interface.go
index e5aa317ae..10015954d 100644
--- a/mocks/systemeventmocks/event_interface.go
+++ b/mocks/systemeventmocks/event_interface.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package systemeventmocks
 
diff --git a/mocks/tokenmocks/callbacks.go b/mocks/tokenmocks/callbacks.go
index d2618b9b0..4dd7e8f7c 100644
--- a/mocks/tokenmocks/callbacks.go
+++ b/mocks/tokenmocks/callbacks.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package tokenmocks
 
diff --git a/mocks/tokenmocks/plugin.go b/mocks/tokenmocks/plugin.go
index 66fc8285c..471e79842 100644
--- a/mocks/tokenmocks/plugin.go
+++ b/mocks/tokenmocks/plugin.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package tokenmocks
 
diff --git a/mocks/txcommonmocks/helper.go b/mocks/txcommonmocks/helper.go
index 2d289053e..9650446e6 100644
--- a/mocks/txcommonmocks/helper.go
+++ b/mocks/txcommonmocks/helper.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package txcommonmocks
 
diff --git a/mocks/wsmocks/ws_client.go b/mocks/wsmocks/ws_client.go
index 814a6800e..f13e87019 100644
--- a/mocks/wsmocks/ws_client.go
+++ b/mocks/wsmocks/ws_client.go
@@ -1,4 +1,4 @@
-// Code generated by mockery v2.14.1. DO NOT EDIT.
+// Code generated by mockery v2.15.0. DO NOT EDIT.
 
 package wsmocks
 
diff --git a/pkg/core/message.go b/pkg/core/message.go
index 2f3163ec0..38f4b6f02 100644
--- a/pkg/core/message.go
+++ b/pkg/core/message.go
@@ -46,6 +46,10 @@ var (
 	MessageTypeTransferBroadcast = fftypes.FFEnumValue("messagetype", "transfer_broadcast")
 	// MessageTypeTransferPrivate is a private message to accompany/annotate a token transfer
 	MessageTypeTransferPrivate = fftypes.FFEnumValue("messagetype", "transfer_private")
+	// MessageTypeApprovalBroadcast is a broadcast message to accompany/annotate a token approval
+	MessageTypeApprovalBroadcast = fftypes.FFEnumValue("messagetype", "approval_broadcast")
+	// MessageTypeApprovalPrivate is a private message to accompany/annotate a token approval
+	MessageTypeApprovalPrivate = fftypes.FFEnumValue("messagetype", "approval_private")
 )
 
 // MessageState is the current transmission/confirmation state of a message
diff --git a/pkg/core/tokenapproval.go b/pkg/core/tokenapproval.go
index d9938efcc..5f54d8b12 100644
--- a/pkg/core/tokenapproval.go
+++ b/pkg/core/tokenapproval.go
@@ -20,6 +20,7 @@ import "github.com/hyperledger/firefly-common/pkg/fftypes"
 
 type TokenApprovalInput struct {
 	TokenApproval
+	Message        *MessageInOut  `ffstruct:"TokenApprovalInput" json:"message,omitempty"`
 	Pool           string         `ffstruct:"TokenApprovalInput" json:"pool,omitempty" ffexcludeoutput:"true"`
 	IdempotencyKey IdempotencyKey `ffstruct:"TokenApprovalInput" json:"idempotencyKey,omitempty" ffexcludeoutput:"true"`
 }
@@ -36,6 +37,8 @@ type TokenApproval struct {
 	ProtocolID      string             `ffstruct:"TokenApproval" json:"protocolId,omitempty" ffexcludeinput:"true"`
 	Subject         string             `ffstruct:"TokenApproval" json:"subject,omitempty" ffexcludeinput:"true"`
 	Active          bool               `ffstruct:"TokenApproval" json:"active,omitempty" ffexcludeinput:"true"`
+	Message         *fftypes.UUID      `ffstruct:"TokenApproval" json:"message,omitempty"`
+	MessageHash     *fftypes.Bytes32   `ffstruct:"TokenApproval" json:"messageHash,omitempty" ffexcludeinput:"true"`
 	Created         *fftypes.FFTime    `ffstruct:"TokenApproval" json:"created,omitempty" ffexcludeinput:"true"`
 	TX              TransactionRef     `ffstruct:"TokenApproval" json:"tx" ffexcludeinput:"true"`
 	BlockchainEvent *fftypes.UUID      `ffstruct:"TokenApproval" json:"blockchainEvent,omitempty" ffexcludeinput:"true"`
diff --git a/pkg/database/plugin.go b/pkg/database/plugin.go
index 5a00162f2..5327df234 100644
--- a/pkg/database/plugin.go
+++ b/pkg/database/plugin.go
@@ -928,6 +928,8 @@ var TokenApprovalQueryFactory = &ffapi.QueryFields{
 	"tx.type":         &ffapi.StringField{},
 	"tx.id":           &ffapi.UUIDField{},
 	"blockchainevent": &ffapi.UUIDField{},
+	"message":         &ffapi.UUIDField{},
+	"messagehash":     &ffapi.Bytes32Field{},
 }
 
 // FFIQueryFactory filter fields for contract definitions


Test patch:
diff --git a/internal/assets/token_approval_test.go b/internal/assets/token_approval_test.go
index 0ade229bc..0e7768c3f 100644
--- a/internal/assets/token_approval_test.go
+++ b/internal/assets/token_approval_test.go
@@ -23,9 +23,11 @@ import (
 	"github.com/hyperledger/firefly-common/pkg/fftypes"
 	"github.com/hyperledger/firefly/internal/identity"
 	"github.com/hyperledger/firefly/internal/syncasync"
+	"github.com/hyperledger/firefly/mocks/broadcastmocks"
 	"github.com/hyperledger/firefly/mocks/databasemocks"
 	"github.com/hyperledger/firefly/mocks/identitymanagermocks"
 	"github.com/hyperledger/firefly/mocks/operationmocks"
+	"github.com/hyperledger/firefly/mocks/privatemessagingmocks"
 	"github.com/hyperledger/firefly/mocks/syncasyncmocks"
 	"github.com/hyperledger/firefly/mocks/txcommonmocks"
 	"github.com/hyperledger/firefly/pkg/core"
@@ -390,6 +392,317 @@ func TestApprovalTransactionFail(t *testing.T) {
 	mdi.AssertExpectations(t)
 }
 
+func TestApprovalWithBroadcastMessage(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+
+	msgID := fftypes.NewUUID()
+	hash := fftypes.NewRandB32()
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					ID: msgID,
+				},
+				Hash: hash,
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+		IdempotencyKey: "idem1",
+	}
+	pool := &core.TokenPool{
+		Connector: "magic-tokens",
+		State:     core.TokenPoolStateConfirmed,
+	}
+
+	mdi := am.database.(*databasemocks.Plugin)
+	mim := am.identity.(*identitymanagermocks.Manager)
+	mbm := am.broadcast.(*broadcastmocks.Manager)
+	mms := &syncasyncmocks.Sender{}
+	mth := am.txHelper.(*txcommonmocks.Helper)
+	mom := am.operations.(*operationmocks.Manager)
+	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
+	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
+	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
+	mbm.On("NewBroadcast", approval.Message).Return(mms)
+	mms.On("Prepare", context.Background()).Return(nil)
+	mms.On("Send", context.Background()).Return(nil)
+	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
+		data := op.Data.(approvalData)
+		return op.Type == core.OpTypeTokenApproval && data.Pool == pool && data.Approval == &approval.TokenApproval
+	})).Return(nil, nil)
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.NoError(t, err)
+	assert.Equal(t, *msgID, *approval.TokenApproval.Message)
+	assert.Equal(t, *hash, *approval.TokenApproval.MessageHash)
+
+	mbm.AssertExpectations(t)
+	mim.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+	mms.AssertExpectations(t)
+	mth.AssertExpectations(t)
+	mom.AssertExpectations(t)
+}
+
+func TestApprovalWithBroadcastMessageDisabled(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+	am.broadcast = nil
+
+	msgID := fftypes.NewUUID()
+	hash := fftypes.NewRandB32()
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					ID: msgID,
+				},
+				Hash: hash,
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+		IdempotencyKey: "idem1",
+	}
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.Regexp(t, "FF10415", err)
+}
+
+func TestApprovalWithBroadcastMessageSendFail(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+
+	msgID := fftypes.NewUUID()
+	hash := fftypes.NewRandB32()
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					ID: msgID,
+				},
+				Hash: hash,
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+		IdempotencyKey: "idem1",
+	}
+	pool := &core.TokenPool{
+		Connector: "magic-tokens",
+		State:     core.TokenPoolStateConfirmed,
+	}
+
+	mdi := am.database.(*databasemocks.Plugin)
+	mim := am.identity.(*identitymanagermocks.Manager)
+	mbm := am.broadcast.(*broadcastmocks.Manager)
+	mms := &syncasyncmocks.Sender{}
+	mth := am.txHelper.(*txcommonmocks.Helper)
+	mom := am.operations.(*operationmocks.Manager)
+	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
+	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
+	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
+	mbm.On("NewBroadcast", approval.Message).Return(mms)
+	mms.On("Prepare", context.Background()).Return(nil)
+	mms.On("Send", context.Background()).Return(fmt.Errorf("pop"))
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.Regexp(t, "pop", err)
+	assert.Equal(t, *msgID, *approval.TokenApproval.Message)
+	assert.Equal(t, *hash, *approval.TokenApproval.MessageHash)
+
+	mbm.AssertExpectations(t)
+	mim.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+	mms.AssertExpectations(t)
+	mth.AssertExpectations(t)
+	mom.AssertExpectations(t)
+}
+
+func TestApprovalWithBroadcastPrepareFail(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+	}
+
+	mbm := am.broadcast.(*broadcastmocks.Manager)
+	mms := &syncasyncmocks.Sender{}
+	mbm.On("NewBroadcast", approval.Message).Return(mms)
+	mms.On("Prepare", context.Background()).Return(fmt.Errorf("pop"))
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.EqualError(t, err, "pop")
+
+	mbm.AssertExpectations(t)
+	mms.AssertExpectations(t)
+}
+
+func TestApprovalWithPrivateMessage(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+
+	msgID := fftypes.NewUUID()
+	hash := fftypes.NewRandB32()
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					ID:   msgID,
+					Type: core.MessageTypeApprovalPrivate,
+				},
+				Hash: hash,
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+		IdempotencyKey: "idem1",
+	}
+	pool := &core.TokenPool{
+		Connector: "magic-tokens",
+		State:     core.TokenPoolStateConfirmed,
+	}
+
+	mdi := am.database.(*databasemocks.Plugin)
+	mim := am.identity.(*identitymanagermocks.Manager)
+	mpm := am.messaging.(*privatemessagingmocks.Manager)
+	mms := &syncasyncmocks.Sender{}
+	mth := am.txHelper.(*txcommonmocks.Helper)
+	mom := am.operations.(*operationmocks.Manager)
+	mim.On("NormalizeSigningKey", context.Background(), "", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
+	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
+	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
+	mpm.On("NewMessage", approval.Message).Return(mms)
+	mms.On("Prepare", context.Background()).Return(nil)
+	mms.On("Send", context.Background()).Return(nil)
+	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
+		data := op.Data.(approvalData)
+		return op.Type == core.OpTypeTokenApproval && data.Pool == pool && data.Approval == &approval.TokenApproval
+	})).Return(nil, nil)
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.NoError(t, err)
+	assert.Equal(t, *msgID, *approval.TokenApproval.Message)
+	assert.Equal(t, *hash, *approval.TokenApproval.MessageHash)
+
+	mpm.AssertExpectations(t)
+	mim.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+	mms.AssertExpectations(t)
+	mth.AssertExpectations(t)
+	mom.AssertExpectations(t)
+}
+
+func TestApprovalWithPrivateMessageDisabled(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+	am.messaging = nil
+
+	msgID := fftypes.NewUUID()
+	hash := fftypes.NewRandB32()
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					ID:   msgID,
+					Type: core.MessageTypeApprovalPrivate,
+				},
+				Hash: hash,
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+	}
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.Regexp(t, "FF10415", err)
+}
+
+func TestApprovalWithInvalidMessage(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Operator: "B",
+			Approved: true,
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					Type: core.MessageTypeDefinition,
+				},
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+	}
+
+	_, err := am.TokenApproval(context.Background(), approval, false)
+	assert.Regexp(t, "FF10287", err)
+}
+
 func TestApprovalOperationsFail(t *testing.T) {
 	am, cancel := newTestAssets(t)
 	defer cancel()
@@ -478,6 +791,83 @@ func TestTokenApprovalConfirm(t *testing.T) {
 	mth.AssertExpectations(t)
 }
 
+func TestApprovalWithBroadcastConfirm(t *testing.T) {
+	am, cancel := newTestAssets(t)
+	defer cancel()
+
+	msgID := fftypes.NewUUID()
+	hash := fftypes.NewRandB32()
+	approval := &core.TokenApprovalInput{
+		TokenApproval: core.TokenApproval{
+			Approved: true,
+			Operator: "operator",
+			Key:      "key",
+		},
+		Pool: "pool1",
+		Message: &core.MessageInOut{
+			Message: core.Message{
+				Header: core.MessageHeader{
+					ID: msgID,
+				},
+				Hash: hash,
+			},
+			InlineData: core.InlineData{
+				{
+					Value: fftypes.JSONAnyPtr("test data"),
+				},
+			},
+		},
+		IdempotencyKey: "idem1",
+	}
+	pool := &core.TokenPool{
+		Connector: "magic-tokens",
+		State:     core.TokenPoolStateConfirmed,
+	}
+
+	mdi := am.database.(*databasemocks.Plugin)
+	mim := am.identity.(*identitymanagermocks.Manager)
+	mbm := am.broadcast.(*broadcastmocks.Manager)
+	mms := &syncasyncmocks.Sender{}
+	msa := am.syncasync.(*syncasyncmocks.Bridge)
+	mth := am.txHelper.(*txcommonmocks.Helper)
+	mom := am.operations.(*operationmocks.Manager)
+	mim.On("NormalizeSigningKey", context.Background(), "key", identity.KeyNormalizationBlockchainPlugin).Return("0x12345", nil)
+	mdi.On("GetTokenPool", context.Background(), "ns1", "pool1").Return(pool, nil)
+	mom.On("AddOrReuseOperation", context.Background(), mock.Anything).Return(nil)
+	mth.On("SubmitNewTransaction", context.Background(), core.TransactionTypeTokenApproval, core.IdempotencyKey("idem1")).Return(fftypes.NewUUID(), nil)
+	mbm.On("NewBroadcast", approval.Message).Return(mms)
+	mms.On("Prepare", context.Background()).Return(nil)
+	mms.On("Send", context.Background()).Return(nil)
+	msa.On("WaitForMessage", context.Background(), mock.Anything, mock.Anything).
+		Run(func(args mock.Arguments) {
+			send := args[2].(syncasync.SendFunction)
+			send(context.Background())
+		}).
+		Return(&core.Message{}, nil)
+	msa.On("WaitForTokenApproval", context.Background(), mock.Anything, mock.Anything).
+		Run(func(args mock.Arguments) {
+			send := args[2].(syncasync.SendFunction)
+			send(context.Background())
+		}).
+		Return(&approval.TokenApproval, nil)
+	mom.On("RunOperation", context.Background(), mock.MatchedBy(func(op *core.PreparedOperation) bool {
+		data := op.Data.(approvalData)
+		return op.Type == core.OpTypeTokenApproval && data.Pool == pool && data.Approval == &approval.TokenApproval
+	})).Return(nil, nil)
+
+	_, err := am.TokenApproval(context.Background(), approval, true)
+	assert.NoError(t, err)
+	assert.Equal(t, *msgID, *approval.TokenApproval.Message)
+	assert.Equal(t, *hash, *approval.TokenApproval.MessageHash)
+
+	mbm.AssertExpectations(t)
+	mim.AssertExpectations(t)
+	mdi.AssertExpectations(t)
+	mms.AssertExpectations(t)
+	msa.AssertExpectations(t)
+	mom.AssertExpectations(t)
+}
+
 func TestApprovalPrepare(t *testing.T) {
 	am, cancel := newTestAssetsWithMetrics(t)
 	defer cancel()
diff --git a/internal/broadcast/manager_test.go b/internal/broadcast/manager_test.go
index c8d122cb5..b13f11924 100644
--- a/internal/broadcast/manager_test.go
+++ b/internal/broadcast/manager_test.go
@@ -67,6 +67,7 @@ func newTestBroadcastCommon(t *testing.T, metricsEnabled bool) (*broadcastManage
 			core.MessageTypeBroadcast,
 			core.MessageTypeDefinition,
 			core.MessageTypeTransferBroadcast,
+			core.MessageTypeApprovalBroadcast,
 		}, mock.Anything, mock.Anything).Return()
 	mom.On("RegisterHandler", mock.Anything, mock.Anything, mock.Anything)
 
diff --git a/internal/privatemessaging/privatemessaging_test.go b/internal/privatemessaging/privatemessaging_test.go
index 91f1b26b6..0dc8f75f6 100644
--- a/internal/privatemessaging/privatemessaging_test.go
+++ b/internal/privatemessaging/privatemessaging_test.go
@@ -71,6 +71,7 @@ func newTestPrivateMessagingCommon(t *testing.T, metricsEnabled bool) (*privateM
 			core.MessageTypeGroupInit,
 			core.MessageTypePrivate,
 			core.MessageTypeTransferPrivate,
+			core.MessageTypeApprovalPrivate,
 		}, mock.Anything, mock.Anything).Return()
 
 	mba.On("RegisterDispatcher",