Added first wallet transaction reward endpoint#471
Added first wallet transaction reward endpoint#471SergeyG-Solicy wants to merge 4 commits intotestnetfrom
Conversation
WalkthroughAdds FIRST_WALLET_TRANSACTION points and supporting breakdown fields, implements awardFirstTransactionReward and non-blocking first-transaction reward checks/awards triggered for native transactions, updates account initialization to include telegram and firstWalletTransaction, and adjusts executeNativeTransaction to return operations on success. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant RT as executeNativeTransaction
participant BC as Blockchain
participant GCR as HandleGCR
participant PS as PointSystem
User->>RT: initiate native transfer
RT->>BC: submit tx
BC-->>RT: success (txHash), operations
RT-->>User: [ok, txHash, operations]
note right of RT: Post-success (non-blocking)
RT->>GCR: apply GCR edits (tx)
GCR-->>GCR: detect native tx & extract participants
par For each participant (async, non-blocking)
GCR->>PS: hasAlreadyReceivedFirstTransactionReward(address)?
alt not received
GCR->>PS: awardFirstTransactionReward(userId, txHash)
PS-->>GCR: success / totalPoints
else already received or error
PS-->>GCR: skip / error logged
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro Disabled knowledge base sources:
📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (1)
🧰 Additional context used🧬 Code graph analysis (2)src/features/incentive/PointSystem.ts (1)
src/libs/blockchain/gcr/handleGCR.ts (3)
🔇 Additional comments (12)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🧪 Early access (Sonnet 4.5): enabledWe are currently testing the Sonnet 4.5 model, which is expected to improve code review quality. However, this model may lead to increased noise levels in the review comments. Please disable the early access features if the noise level causes any inconvenience. Note:
Comment |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Explore these optional code suggestions:
|
|||||||||||||||
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/model/entities/GCRv2/GCR_Main.ts (1)
37-41: Make field optional or ensure backfill/defaultsOlder rows won’t have this JSON key. Typing it as required increases mismatch risk and can cause runtime writes to fail elsewhere if
breakdownis missing. Prefer making it optional (or guarantee a 0 default on create/backfill).- firstWalletTransaction: number + firstWalletTransaction?: numbersrc/libs/network/server_rpc.ts (1)
370-377: Return JSON with proper headers for “/”Align with other routes using
jsonResponse(sets content-type, consistent formatting).- server.get("/", req => { + server.get("/", req => { const clientIP = rateLimiter.getClientIP(req, server.server) - return new Response( - JSON.stringify({ - message: "Hello, World!", - yourIP: clientIP, - }), - ) + return jsonResponse({ + message: "Hello, World!", + yourIP: clientIP, + }) })
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (3)
src/features/incentive/PointSystem.ts(5 hunks)src/libs/network/server_rpc.ts(3 hunks)src/model/entities/GCRv2/GCR_Main.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/features/incentive/PointSystem.ts (1)
src/utilities/logger.ts (1)
error(125-132)
src/libs/network/server_rpc.ts (1)
src/features/incentive/PointSystem.ts (1)
PointSystem(21-928)
🔇 Additional comments (4)
src/features/incentive/PointSystem.ts (4)
18-19: LGTM: point value definedConstant fits existing pattern and type.
130-132: LGTM: safe read with fallbackGracefully handles missing JSON key.
147-151: LGTM: type union extendedPublic API surface matches new category.
144-156: Hidden message: awaiting script results.
| async awardFirstTransactionReward( | ||
| userId: string, | ||
| transactionHash: string, | ||
| ): Promise<RPCResponse> { | ||
| try { | ||
| const userPointsWithIdentities = await this.getUserPointsInternal( | ||
| userId, | ||
| ) | ||
|
|
||
| if (userPointsWithIdentities.breakdown.firstWalletTransaction > 0) { | ||
| return { | ||
| result: 200, | ||
| response: { | ||
| pointsAwarded: 0, | ||
| totalPoints: userPointsWithIdentities.totalPoints, | ||
| message: "First transaction reward already awarded", | ||
| alreadyAwarded: true, | ||
| }, | ||
| require_reply: false, | ||
| extra: { transactionHash }, | ||
| } | ||
| } | ||
|
|
||
| await this.addPointsToGCR( | ||
| userId, | ||
| pointValues.FIRST_WALLET_TRANSACTION, | ||
| "firstWalletTransaction", | ||
| "", | ||
| ) | ||
|
|
||
| const updatedPoints = await this.getUserPointsInternal(userId) | ||
|
|
||
| log.info( | ||
| `[PointSystem] First transaction reward awarded to ${userId}. Hash: ${transactionHash}`, | ||
| ) | ||
|
|
||
| return { | ||
| result: 200, | ||
| response: { | ||
| pointsAwarded: pointValues.FIRST_WALLET_TRANSACTION, | ||
| totalPoints: updatedPoints.totalPoints, | ||
| message: "Points awarded for first wallet transaction", | ||
| alreadyAwarded: false, | ||
| }, | ||
| require_reply: false, | ||
| extra: { transactionHash }, | ||
| } | ||
| } catch (error) { | ||
| log.error( | ||
| `[PointSystem] Error awarding first transaction reward to ${userId}: ${error}`, | ||
| ) | ||
|
|
||
| return { | ||
| result: 500, | ||
| response: "Error awarding first transaction reward", | ||
| require_reply: false, | ||
| extra: { | ||
| error: | ||
| error instanceof Error ? error.message : String(error), | ||
| transactionHash, | ||
| }, | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Race: not truly idempotent under concurrent calls
Two parallel calls can both see 0 and double‑award. Lock and recheck within a transaction (or perform a conditional update).
- async awardFirstTransactionReward(
+ async awardFirstTransactionReward(
userId: string,
transactionHash: string,
): Promise<RPCResponse> {
try {
- const userPointsWithIdentities = await this.getUserPointsInternal(userId)
- if (userPointsWithIdentities.breakdown.firstWalletTransaction > 0) {
- return { ... }
- }
-
- await this.addPointsToGCR(
- userId,
- pointValues.FIRST_WALLET_TRANSACTION,
- "firstWalletTransaction",
- "",
- )
-
- const updatedPoints = await this.getUserPointsInternal(userId)
+ const db = await Datasource.getInstance()
+ const dataSource = db.getDataSource()
+ let updatedTotal = 0
+
+ const alreadyAwarded = await dataSource.transaction(
+ "READ COMMITTED",
+ async manager => {
+ const account = await manager.findOne(GCRMain, {
+ where: { pubkey: userId },
+ lock: { mode: "pessimistic_write" }, // FOR UPDATE
+ })
+ if (!account) return false
+ const current =
+ account.points?.breakdown?.firstWalletTransaction || 0
+ if (current > 0) return true
+
+ // Initialize structure defensively
+ account.points = account.points || { totalPoints: 0, breakdown: {} as any, lastUpdated: new Date() }
+ account.points.breakdown = account.points.breakdown || {
+ web3Wallets: {},
+ socialAccounts: { twitter: 0, github: 0, discord: 0 },
+ referrals: 0,
+ demosFollow: 0,
+ firstWalletTransaction: 0,
+ }
+ account.points.breakdown.firstWalletTransaction =
+ (account.points.breakdown.firstWalletTransaction || 0) +
+ pointValues.FIRST_WALLET_TRANSACTION
+ account.points.totalPoints =
+ (account.points.totalPoints || 0) +
+ pointValues.FIRST_WALLET_TRANSACTION
+ account.points.lastUpdated = new Date()
+ await manager.save(account)
+ updatedTotal = account.points.totalPoints
+ return false
+ },
+ )
+
+ if (alreadyAwarded) {
+ return {
+ result: 200,
+ response: {
+ pointsAwarded: 0,
+ totalPoints: (await this.getUserPointsInternal(userId))
+ .totalPoints,
+ message: "First transaction reward already awarded",
+ alreadyAwarded: true,
+ },
+ require_reply: false,
+ extra: { transactionHash },
+ }
+ }
log.info(
`[PointSystem] First transaction reward awarded to ${userId}. Hash: ${transactionHash}`,
)
return {
result: 200,
response: {
pointsAwarded: pointValues.FIRST_WALLET_TRANSACTION,
- totalPoints: updatedPoints.totalPoints,
+ totalPoints: updatedTotal,
message: "Points awarded for first wallet transaction",
alreadyAwarded: false,
},
require_reply: false,
extra: { transactionHash },
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async awardFirstTransactionReward( | |
| userId: string, | |
| transactionHash: string, | |
| ): Promise<RPCResponse> { | |
| try { | |
| const userPointsWithIdentities = await this.getUserPointsInternal( | |
| userId, | |
| ) | |
| if (userPointsWithIdentities.breakdown.firstWalletTransaction > 0) { | |
| return { | |
| result: 200, | |
| response: { | |
| pointsAwarded: 0, | |
| totalPoints: userPointsWithIdentities.totalPoints, | |
| message: "First transaction reward already awarded", | |
| alreadyAwarded: true, | |
| }, | |
| require_reply: false, | |
| extra: { transactionHash }, | |
| } | |
| } | |
| await this.addPointsToGCR( | |
| userId, | |
| pointValues.FIRST_WALLET_TRANSACTION, | |
| "firstWalletTransaction", | |
| "", | |
| ) | |
| const updatedPoints = await this.getUserPointsInternal(userId) | |
| log.info( | |
| `[PointSystem] First transaction reward awarded to ${userId}. Hash: ${transactionHash}`, | |
| ) | |
| return { | |
| result: 200, | |
| response: { | |
| pointsAwarded: pointValues.FIRST_WALLET_TRANSACTION, | |
| totalPoints: updatedPoints.totalPoints, | |
| message: "Points awarded for first wallet transaction", | |
| alreadyAwarded: false, | |
| }, | |
| require_reply: false, | |
| extra: { transactionHash }, | |
| } | |
| } catch (error) { | |
| log.error( | |
| `[PointSystem] Error awarding first transaction reward to ${userId}: ${error}`, | |
| ) | |
| return { | |
| result: 500, | |
| response: "Error awarding first transaction reward", | |
| require_reply: false, | |
| extra: { | |
| error: | |
| error instanceof Error ? error.message : String(error), | |
| transactionHash, | |
| }, | |
| } | |
| } | |
| } | |
| async awardFirstTransactionReward( | |
| userId: string, | |
| transactionHash: string, | |
| ): Promise<RPCResponse> { | |
| try { | |
| // Begin a transaction to lock the user’s points row and prevent races | |
| const db = await Datasource.getInstance(); | |
| const dataSource = db.getDataSource(); | |
| let updatedTotal = 0; | |
| const alreadyAwarded = await dataSource.transaction( | |
| "READ COMMITTED", | |
| async manager => { | |
| const account = await manager.findOne(GCRMain, { | |
| where: { pubkey: userId }, | |
| lock: { mode: "pessimistic_write" }, // FOR UPDATE | |
| }); | |
| if (!account) return false; | |
| const current = | |
| account.points?.breakdown?.firstWalletTransaction || 0; | |
| if (current > 0) return true; | |
| // Ensure the points structure is initialized | |
| account.points = account.points || { | |
| totalPoints: 0, | |
| breakdown: {} as any, | |
| lastUpdated: new Date(), | |
| }; | |
| account.points.breakdown = | |
| account.points.breakdown || { | |
| web3Wallets: {}, | |
| socialAccounts: { twitter: 0, github: 0, discord: 0 }, | |
| referrals: 0, | |
| demosFollow: 0, | |
| firstWalletTransaction: 0, | |
| }; | |
| // Award the first‐transaction bonus | |
| account.points.breakdown.firstWalletTransaction = | |
| (account.points.breakdown.firstWalletTransaction || 0) + | |
| pointValues.FIRST_WALLET_TRANSACTION; | |
| account.points.totalPoints = | |
| (account.points.totalPoints || 0) + | |
| pointValues.FIRST_WALLET_TRANSACTION; | |
| account.points.lastUpdated = new Date(); | |
| await manager.save(account); | |
| updatedTotal = account.points.totalPoints; | |
| return false; | |
| }, | |
| ); | |
| if (alreadyAwarded) { | |
| return { | |
| result: 200, | |
| response: { | |
| pointsAwarded: 0, | |
| totalPoints: ( | |
| await this.getUserPointsInternal(userId) | |
| ).totalPoints, | |
| message: "First transaction reward already awarded", | |
| alreadyAwarded: true, | |
| }, | |
| require_reply: false, | |
| extra: { transactionHash }, | |
| }; | |
| } | |
| log.info( | |
| `[PointSystem] First transaction reward awarded to ${userId}. Hash: ${transactionHash}`, | |
| ); | |
| return { | |
| result: 200, | |
| response: { | |
| pointsAwarded: pointValues.FIRST_WALLET_TRANSACTION, | |
| totalPoints: updatedTotal, | |
| message: "Points awarded for first wallet transaction", | |
| alreadyAwarded: false, | |
| }, | |
| require_reply: false, | |
| extra: { transactionHash }, | |
| }; | |
| } catch (error) { | |
| log.error( | |
| `[PointSystem] Error awarding first transaction reward to ${userId}: ${error}`, | |
| ); | |
| return { | |
| result: 500, | |
| response: "Error awarding first transaction reward", | |
| require_reply: false, | |
| extra: { | |
| error: | |
| error instanceof Error ? error.message : String(error), | |
| transactionHash, | |
| }, | |
| }; | |
| } | |
| } |
src/libs/network/server_rpc.ts
Outdated
| case "awardFirstTransactionReward": { | ||
| const pointSystem = PointSystem.getInstance() | ||
|
|
||
| const params = payload.params[0] | ||
| const { publicKey, transactionType } = params | ||
|
|
||
| if (!publicKey) { | ||
| return { | ||
| result: 400, | ||
| response: "Missing required parameter: publicKey", | ||
| require_reply: false, | ||
| extra: null, | ||
| } | ||
| } | ||
|
|
||
| if (transactionType !== "first_wallet_transaction") { | ||
| return { | ||
| result: 400, | ||
| response: | ||
| "Invalid transaction type. Expected 'first_wallet_transaction'", | ||
| require_reply: false, | ||
| extra: null, | ||
| } | ||
| } | ||
|
|
||
| const result = await pointSystem.awardFirstTransactionReward( | ||
| publicKey, | ||
| params.transactionHash, | ||
| ) | ||
|
|
||
| return result | ||
| } |
There was a problem hiding this comment.
Critical: Bind caller identity to publicKey (or restrict to SUDO)
As written, any authenticated caller can award this reward to any publicKey. Enforce sender === publicKey or allow SUDO to act on behalf of others.
case "awardFirstTransactionReward": {
const pointSystem = PointSystem.getInstance()
const params = payload.params[0]
- const { publicKey, transactionType } = params
+ const { publicKey, transactionType } = params
if (!publicKey) {
...
}
+ // Enforce caller binding (or SUDO override)
+ const isSudo = sender === getSharedState.SUDO_PUBKEY
+ if (!isSudo && publicKey !== sender) {
+ return {
+ result: 401,
+ response: "Unauthorized: caller does not match publicKey",
+ require_reply: false,
+ extra: null,
+ }
+ }
+
if (transactionType !== "first_wallet_transaction") {
...
}
+ if (
+ typeof params.transactionHash !== "string" ||
+ params.transactionHash.length === 0
+ ) {
+ return {
+ result: 400,
+ response: "Missing or invalid parameter: transactionHash",
+ require_reply: false,
+ extra: null,
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case "awardFirstTransactionReward": { | |
| const pointSystem = PointSystem.getInstance() | |
| const params = payload.params[0] | |
| const { publicKey, transactionType } = params | |
| if (!publicKey) { | |
| return { | |
| result: 400, | |
| response: "Missing required parameter: publicKey", | |
| require_reply: false, | |
| extra: null, | |
| } | |
| } | |
| if (transactionType !== "first_wallet_transaction") { | |
| return { | |
| result: 400, | |
| response: | |
| "Invalid transaction type. Expected 'first_wallet_transaction'", | |
| require_reply: false, | |
| extra: null, | |
| } | |
| } | |
| const result = await pointSystem.awardFirstTransactionReward( | |
| publicKey, | |
| params.transactionHash, | |
| ) | |
| return result | |
| } | |
| case "awardFirstTransactionReward": { | |
| const pointSystem = PointSystem.getInstance() | |
| const params = payload.params[0] | |
| const { publicKey, transactionType } = params | |
| if (!publicKey) { | |
| return { | |
| result: 400, | |
| response: "Missing required parameter: publicKey", | |
| require_reply: false, | |
| extra: null, | |
| } | |
| } | |
| // Enforce caller binding (or SUDO override) | |
| const isSudo = sender === getSharedState.SUDO_PUBKEY | |
| if (!isSudo && publicKey !== sender) { | |
| return { | |
| result: 401, | |
| response: "Unauthorized: caller does not match publicKey", | |
| require_reply: false, | |
| extra: null, | |
| } | |
| } | |
| if (transactionType !== "first_wallet_transaction") { | |
| return { | |
| result: 400, | |
| response: | |
| "Invalid transaction type. Expected 'first_wallet_transaction'", | |
| require_reply: false, | |
| extra: null, | |
| } | |
| } | |
| if ( | |
| typeof params.transactionHash !== "string" || | |
| params.transactionHash.length === 0 | |
| ) { | |
| return { | |
| result: 400, | |
| response: "Missing or invalid parameter: transactionHash", | |
| require_reply: false, | |
| extra: null, | |
| } | |
| } | |
| const result = await pointSystem.awardFirstTransactionReward( | |
| publicKey, | |
| params.transactionHash, | |
| ) | |
| return result | |
| } |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
src/libs/blockchain/routines/executeNativeTransaction.ts (3)
34-50: Avoid TOCTOU pre-check; drop this helper and let the award method enforce idempotency.This helper duplicates the check that awardFirstTransactionReward already performs, adds extra I/O, and widens the race window. Prefer calling awardFirstTransactionReward directly and handling its “already awarded” response.
Apply this diff to remove the helper:
-async function hasAlreadyReceivedFirstTransactionReward( - address: string, -): Promise<boolean> { - try { - const pointSystem = PointSystem.getInstance() - - const response = await pointSystem.getUserPoints(address) - - if (response.result === 200 && response.response) { - return response.response.breakdown.firstWalletTransaction > 0 - } - - return false - } catch (error) { - return false - } -}
52-92: Refactor to call the idempotent award API directly, dedupe addresses, and handle both outcomes.Removes the TOCTOU pre-check, reduces DB calls, and narrows race windows. Also handles sender===receiver explicitly and logs “already awarded” responses.
-async function checkAndAwardFirstTransactionRewards( - sender: string, - receiver: string, - txHash: string, -): Promise<void> { - const pointSystem = PointSystem.getInstance() - - try { - if (!(await hasAlreadyReceivedFirstTransactionReward(sender))) { - log.info( - `[FirstTransactionReward] Awarding points to sender: ${sender} for tx: ${txHash}`, - ) - - try { - await pointSystem.awardFirstTransactionReward(sender, txHash) - } catch (error) { - log.error( - `[FirstTransactionReward] Failed to award points to sender ${sender}: ${error}`, - ) - } - } - - if (!(await hasAlreadyReceivedFirstTransactionReward(receiver))) { - log.info( - `[FirstTransactionReward] Awarding points to receiver: ${receiver} for tx: ${txHash}`, - ) - - try { - await pointSystem.awardFirstTransactionReward(receiver, txHash) - } catch (error) { - log.error( - `[FirstTransactionReward] Failed to award points to receiver ${receiver}: ${error}`, - ) - } - } - } catch (error) { - log.error( - `[FirstTransactionReward] Error checking first transaction rewards: ${error}`, - ) - } -} +async function checkAndAwardFirstTransactionRewards( + sender: string, + receiver: string, + txHash: string, +): Promise<void> { + const pointSystem = PointSystem.getInstance() + const addresses = sender === receiver ? [sender] : [sender, receiver] + + await Promise.allSettled( + addresses.map(async addr => { + try { + log.info( + `[FirstTransactionReward] Attempting award for ${addr} (tx: ${txHash})`, + ) + const res = await pointSystem.awardFirstTransactionReward(addr, txHash) + if (res.result !== 200) { + log.error( + `[FirstTransactionReward] awardFirstTransactionReward returned ${res.result} for ${addr} (tx: ${txHash})`, + ) + } else if (res.response?.alreadyAwarded) { + log.info( + `[FirstTransactionReward] Already awarded for ${addr}, skipping (tx: ${txHash})`, + ) + } + } catch (error) { + log.error( + `[FirstTransactionReward] Failed to award points to ${addr}: ${error}`, + ) + } + }), + ) +}
52-92: Ensure true single-award semantics under concurrency across nodes.awardFirstTransactionReward (in PointSystem) reads then writes; without an atomic guard, two nodes could award simultaneously. Please confirm the repository performs an atomic “set if zero” or enforces a unique constraint for this reward type per user, or wrap in a DB transaction/compare-and-set to prevent double-awards.
You could persist a reward ledger with a unique key on (userId, rewardType) and upsert using a single atomic write; or use $setOnInsert with a unique index in Mongo/SQL equivalent. Relevant to the call sites here. (Based on relevant code snippet: PointSystem.awardFirstTransactionReward.)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
src/libs/blockchain/routines/executeNativeTransaction.ts(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/libs/blockchain/routines/executeNativeTransaction.ts (2)
src/features/incentive/PointSystem.ts (1)
PointSystem(21-928)src/utilities/logger.ts (1)
error(125-132)
🔇 Additional comments (1)
src/libs/blockchain/routines/executeNativeTransaction.ts (1)
20-21: LGTM on new imports.Assuming project path aliases are configured, these look correct.
If not already, ensure tsconfig paths resolve "@/features/" and "@/utilities/" at runtime (ts-node/build tooling).
| checkAndAwardFirstTransactionRewards( | ||
| sender, | ||
| receiver, | ||
| transaction.hash, | ||
| ).catch(error => { | ||
| log.error( | ||
| `[FirstTransactionReward] Background reward processing failed: ${error}`, | ||
| ) | ||
| }) | ||
|
|
There was a problem hiding this comment.
Don’t award pre-consensus; trigger after the operation is applied/confirmed.
executeNativeTransaction runs before consensus/GCR apply (see note at Lines 12–15). Awarding here can grant points for transactions that never finalize or get reorged.
Prefer hooking into the GCR apply/commit path (when the “add_native/remove_native” ops transition to applied) or a block-finalization event, then enqueue award work (idempotent) via a job queue/outbox to ensure durability and retries.
🤖 Prompt for AI Agents
In src/libs/blockchain/routines/executeNativeTransaction.ts around lines
144–153, the code awards first-transaction rewards during
executeNativeTransaction which runs before consensus/GCR apply; move this logic
out of the pre-consensus path. Remove the direct call to
checkAndAwardFirstTransactionRewards here and instead enqueue an idempotent
"award-first-transaction" job (including tx hash, sender, receiver, and
metadata) from the GCR apply/commit path or block-finalization handler when the
"add_native"/"remove_native" ops transition to applied; use the outbox/job-queue
for durability and retries, ensure the worker that processes the job is
idempotent and logs errors (with full error details) rather than awarding
inline.
Summary by CodeRabbit
New Features
Bug Fixes
Refactor