Skip to content

Commit 00baf88

Browse files
Merge pull request #621 from autonomys/feat/pay-with-ai3/step-10-payment-robustness
Feat/pay with ai3/step 10 payment robustness
2 parents f353ce9 + 901b8a5 commit 00baf88

13 files changed

Lines changed: 812 additions & 36 deletions

File tree

apps/backend/.env.sample

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,60 @@ FILES_GATEWAY_URL=http://changeme.com # change to your own (or it'll fail when f
44
FILES_GATEWAY_TOKEN=changeme # change to your own (or it'll fail when fetching archived files)
55
AUTH_SERVICE_API_KEY=1234567890 # change to your own if updated in auth
66
RABBITMQ_URL=amqp://guest:guest@localhost:5672
7-
EVM_CHAIN_ENDPOINT=http://localhost:8545 # update it you want to simulate/test buy credits feature
8-
EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000 # update it you want to simulate/test buy credits feature
7+
8+
# ---------------------------------------------------------------------------
9+
# Pay-with-AI3 / purchased credits feature
10+
# ---------------------------------------------------------------------------
11+
12+
# Feature flags — set BUY_CREDITS_ACTIVE=true to enable the purchase flow.
13+
# BUY_CREDITS_STAFF_ONLY=true restricts it to admin/staff accounts only,
14+
# useful for a staged rollout before opening to all users.
15+
BUY_CREDITS_ACTIVE=false
16+
BUY_CREDITS_STAFF_ONLY=false
17+
18+
# EVM endpoint for the AutoDriveCreditsReceiver contract.
19+
# Point this at the Auto-EVM RPC for the target network (mainnet or Taurus
20+
# testnet). The payment manager watches this chain for deposit events.
21+
EVM_CHAIN_ENDPOINT=http://localhost:8545
22+
23+
# Address of the deployed AutoDriveCreditsReceiver contract.
24+
# Must match the chain pointed to by EVM_CHAIN_ENDPOINT.
25+
EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000
26+
27+
# Number of block confirmations to wait before treating a payment as final.
28+
# Higher values reduce the risk of processing a payment that is later
29+
# reversed by a chain reorganisation. Default: 6.
30+
EVM_CHAIN_CONFIRMATIONS=6
31+
32+
# How often (in milliseconds) the payment manager polls for CONFIRMED intents
33+
# that have not yet had credits applied. This is a fallback for cases where
34+
# the event watcher misses a log. Default: 30000 (30 seconds).
35+
EVM_CHAIN_CHECK_INTERVAL=30000
36+
37+
# Price multiplier applied on top of the raw Autonomys consensus fee to
38+
# determine the AI3 cost per byte. A value of 5.0 means users pay 5× the
39+
# current on-chain transaction byte fee. Default: 5.00.
40+
CREDITS_PRICE_MULTIPLIER=5.00
41+
42+
# ---------------------------------------------------------------------------
43+
# Credit lifecycle
44+
# ---------------------------------------------------------------------------
45+
46+
# Number of days after purchase before a credit batch expires.
47+
# Users see this value on the purchase confirmation screen and in their credit
48+
# history. Default: 90.
49+
CREDIT_EXPIRY_DAYS=90
50+
51+
# Maximum total purchased upload bytes allowed per user across all active
52+
# (non-expired) credit rows. Attempts to purchase beyond this cap result in
53+
# an OVER_CAP intent requiring admin review. Default: 107374182400 (100 GiB).
54+
MAX_CREDITS_PER_USER=107374182400
55+
56+
# How often (in milliseconds) the background job runs to mark expired credit
57+
# rows and clean up stale PENDING intents. Default: 3600000 (1 hour).
58+
CREDIT_EXPIRY_CHECK_INTERVAL=3600000
59+
60+
# How many minutes a PENDING intent remains valid before it is treated as
61+
# expired. Users must submit their on-chain transaction within this window
62+
# after creating an intent. Default: 10.
63+
INTENT_EXPIRY_MINUTES=10

apps/backend/src/app/controllers/credits.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,44 @@ creditsController.get(
117117
}),
118118
)
119119

120+
// ---------------------------------------------------------------------------
121+
// GET /credits/batches/all
122+
// Admin-only: all credit batches across every user, newest-first.
123+
// Each row includes the owner's userPublicId for easy cross-referencing
124+
// with the admin user table. Returns 403 for non-admin callers.
125+
//
126+
// NOTE: registered BEFORE GET /credits/batches so Express does not attempt
127+
// to match the literal string "all" against the existing /batches route
128+
// (they are separate paths and Express won't confuse them, but ordering
129+
// here keeps the admin routes grouped together).
130+
// ---------------------------------------------------------------------------
131+
132+
creditsController.get(
133+
'/batches/all',
134+
asyncSafeHandler(async (req, res) => {
135+
const user = await handleAuth(req, res)
136+
if (!user) {
137+
return
138+
}
139+
140+
const result = await handleInternalErrorResult(
141+
CreditsUseCases.getAllBatches(user),
142+
'Failed to get all credit batches',
143+
)
144+
if (result.isErr()) {
145+
handleError(result.error, res)
146+
return
147+
}
148+
149+
res.status(200).json(
150+
result.value.map((batch) => ({
151+
...serializeCredit(batch),
152+
userPublicId: batch.userPublicId,
153+
})),
154+
)
155+
}),
156+
)
157+
120158
// ---------------------------------------------------------------------------
121159
// GET /credits/economics
122160
// Admin-only: system-wide credit stats (expiring totals, byte volumes).

apps/backend/src/core/users/credits.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { PurchasedCredit, User, UserRole, UserWithOrganization } from '@auto-drive/models'
2-
import { purchasedCreditsRepository } from '../../infrastructure/repositories/users/purchasedCredits.js'
2+
import {
3+
AdminCreditBatchRow,
4+
purchasedCreditsRepository,
5+
} from '../../infrastructure/repositories/users/purchasedCredits.js'
36
import { AccountsUseCases } from './accounts.js'
47
import { config } from '../../config.js'
58
import { ForbiddenError } from '../../errors/index.js'
@@ -135,9 +138,30 @@ const getEconomics = async (
135138
})
136139
}
137140

141+
// ---------------------------------------------------------------------------
142+
// getAllBatches
143+
// Admin-only: full purchase history across all users with their publicId.
144+
// Returns 403 for non-admin callers.
145+
// ---------------------------------------------------------------------------
146+
147+
const getAllBatches = async (
148+
executor: User,
149+
): Promise<Result<AdminCreditBatchRow[], ForbiddenError>> => {
150+
if (executor.role !== UserRole.Admin) {
151+
logger.warn('Non-admin user attempted to access all credit batches', {
152+
publicId: executor.publicId,
153+
})
154+
return err(new ForbiddenError('Admin access required'))
155+
}
156+
157+
const rows = await purchasedCreditsRepository.getAllWithUserPublicId()
158+
return ok(rows)
159+
}
160+
138161
export const CreditsUseCases = {
139162
getSummary,
140163
getBatches,
141164
getExpiringBatches,
142165
getEconomics,
166+
getAllBatches,
143167
}

apps/backend/src/core/users/intents.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,30 @@ const markIntentAsConfirmed = async ({
155155
return err(new ObjectNotFoundError('Intent not found'))
156156
}
157157

158+
// Idempotency guard — do not overwrite an intent that is already in a
159+
// post-PENDING state. Duplicate calls arise from:
160+
// • chain reorgs causing the same event to be re-emitted
161+
// • the payment manager reconnecting and re-processing already-seen logs
162+
// • watchTransaction and the _checkConfirmedIntents polling loop racing
163+
//
164+
// We return ok() rather than an error so the caller does not treat a
165+
// duplicate as a failure and does not retry indefinitely.
166+
if (
167+
intent.status === IntentStatus.CONFIRMED ||
168+
intent.status === IntentStatus.COMPLETED ||
169+
intent.status === IntentStatus.OVER_CAP ||
170+
intent.status === IntentStatus.FAILED ||
171+
intent.status === IntentStatus.EXPIRED
172+
) {
173+
logger.info('markIntentAsConfirmed: intent already processed — skipping', {
174+
intentId,
175+
currentStatus: intent.status,
176+
})
177+
return ok(intent)
178+
}
179+
158180
return ok(
159-
intentsRepository.updateIntent({
181+
await intentsRepository.updateIntent({
160182
...intent,
161183
status: IntentStatus.CONFIRMED,
162184
paymentAmount,
@@ -189,9 +211,37 @@ const onConfirmedIntent = async (intentId: string) => {
189211
return err(new Error('Intent has no deposit amount'))
190212
}
191213

214+
// Guard: reject payments whose value is too small to purchase even a single
215+
// byte of storage. getIntentCredits divides paymentAmount by shannonsPerByte
216+
// using BigInt integer division, so a dust payment (paymentAmount <
217+
// shannonsPerByte) yields 0 credits. Granting 0 credits would mark the
218+
// intent COMPLETED while giving the user nothing — a misleading outcome that
219+
// wastes a DB row and silently discards the payment.
220+
//
221+
// Both paymentAmount and shannonsPerByte are immutable on a confirmed intent,
222+
// so this condition is permanent. We mark the intent FAILED (terminal) so
223+
// the polling loop stops retrying. The on-chain payment is irreversible;
224+
// resolution requires admin review (similar to OVER_CAP handling).
225+
const creditBytes = IntentsUseCases.getIntentCredits(intent)
226+
if (creditBytes === BigInt(0)) {
227+
logger.warn(
228+
'onConfirmedIntent: payment too small to yield any credits — marking FAILED',
229+
{
230+
intentId,
231+
paymentAmount: intent.paymentAmount.toString(),
232+
shannonsPerByte: intent.shannonsPerByte.toString(),
233+
},
234+
)
235+
await intentsRepository.updateIntent({
236+
...intent,
237+
status: IntentStatus.FAILED,
238+
})
239+
return ok()
240+
}
241+
192242
const addResult = await AccountsUseCases.addCreditsToAccount(
193243
intent.userPublicId,
194-
IntentsUseCases.getIntentCredits(intent),
244+
creditBytes,
195245
intentId,
196246
)
197247

@@ -326,6 +376,13 @@ const getPrice = async (): Promise<{ price: number; pricePerGB: number }> => {
326376
}
327377
}
328378

379+
// Returns PENDING intents that already have a tx_hash — used by the payment
380+
// manager startup sweep to re-watch transactions that were submitted but never
381+
// confirmed due to a service restart or RPC outage.
382+
const getPendingWithTxHash = async (): Promise<Intent[]> => {
383+
return intentsRepository.getPendingWithTxHash()
384+
}
385+
329386
export const IntentsUseCases = {
330387
createIntent,
331388
getIntent,
@@ -335,6 +392,7 @@ export const IntentsUseCases = {
335392
markIntentAsConfirmed,
336393
getConfirmedIntents,
337394
getOverCapIntents,
395+
getPendingWithTxHash,
338396
reprocessOverCapIntent,
339397
getIntentCredits,
340398
getPrice,

apps/backend/src/infrastructure/repositories/users/intents.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,23 @@ const expireIntentIfPending = async (intentId: string): Promise<boolean> => {
117117
return (result.rowCount ?? 0) > 0
118118
}
119119

120+
// Returns PENDING intents that already have an on-chain tx_hash.
121+
// These are intents where the user submitted a transaction but the payment
122+
// manager did not process the confirmation event — typically because the
123+
// service was restarted or the EVM RPC was temporarily unavailable.
124+
// Used by the startup recovery sweep so that no paid transaction is silently
125+
// abandoned across a service restart.
126+
const getPendingWithTxHash = async (): Promise<Intent[]> => {
127+
const db = await getDatabase()
128+
const result = await db.query<DBIntent>(
129+
`SELECT * FROM intents
130+
WHERE status = $1
131+
AND tx_hash IS NOT NULL`,
132+
[IntentStatus.PENDING],
133+
)
134+
return mapRows(result.rows)
135+
}
136+
120137
// Returns all intents that were blocked by the per-user cap.
121138
// These are terminal — the polling loop skips them — and require admin review.
122139
const getOverCapIntents = async (): Promise<Intent[]> => {
@@ -136,4 +153,5 @@ export const intentsRepository = {
136153
getExpiredPendingIntents,
137154
expireIntentIfPending,
138155
getOverCapIntents,
156+
getPendingWithTxHash,
139157
}

apps/backend/src/infrastructure/repositories/users/purchasedCredits.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,30 @@ const createPurchasedCreditWithCapCheck = async (
547547
}
548548
}
549549

550+
// ---------------------------------------------------------------------------
551+
// getAllWithUserPublicId
552+
// Admin view: every credit batch across all users, joined with the
553+
// user_public_id from the originating intent row. Ordered newest-first.
554+
// ---------------------------------------------------------------------------
555+
556+
export type AdminCreditBatchRow = PurchasedCredit & {
557+
userPublicId: string
558+
}
559+
560+
const getAllWithUserPublicId = async (): Promise<AdminCreditBatchRow[]> => {
561+
const db = await getDatabase()
562+
const result = await db.query<DBPurchasedCredit & { user_public_id: string }>(
563+
`SELECT pc.*, i.user_public_id
564+
FROM purchased_credits pc
565+
JOIN intents i ON i.id = pc.intent_id
566+
ORDER BY pc.purchased_at DESC`,
567+
)
568+
return result.rows.map((row) => ({
569+
...mapRow(row),
570+
userPublicId: row.user_public_id,
571+
}))
572+
}
573+
550574
// ---------------------------------------------------------------------------
551575
// Public API
552576
// ---------------------------------------------------------------------------
@@ -563,4 +587,5 @@ export const purchasedCreditsRepository = {
563587
createPurchasedCreditWithCapCheck,
564588
markExpiredCredits,
565589
getByAccountId,
590+
getAllWithUserPublicId,
566591
}

apps/backend/src/infrastructure/services/paymentManager/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,56 @@ const parseEventLogs = <
122122
let checkInterval: NodeJS.Timeout | null = null
123123
let unwatchContractEvent: (() => void) | null = null
124124

125+
// On startup, re-watch any PENDING intents that already have a tx_hash.
126+
// These represent transactions submitted by users before the last service
127+
// restart or during an EVM RPC outage. The cleanup job explicitly skips
128+
// PENDING+txHash rows (they are not abandoned — they are actively watched),
129+
// so without this sweep they would sit in limbo indefinitely: the user paid
130+
// on-chain but receives no credits.
131+
//
132+
// Re-calling watchTransaction for each orphan is safe:
133+
// • waitForTransactionReceipt returns immediately for already-mined txs
134+
// • markIntentAsConfirmed is idempotent — a duplicate CONFIRMED write is a
135+
// no-op if the intent was already processed before the restart
136+
const _recoverOrphanedTransactions = async () => {
137+
const pending = await IntentsUseCases.getPendingWithTxHash()
138+
if (pending.length === 0) {
139+
logger.info('Startup recovery: no orphaned transactions found')
140+
return
141+
}
142+
143+
logger.info('Startup recovery: re-watching orphaned transactions', {
144+
count: pending.length,
145+
intentIds: pending.map((i) => i.id),
146+
})
147+
148+
await Promise.allSettled(
149+
pending.map(async (intent) => {
150+
if (!intent.txHash) return
151+
try {
152+
await paymentManager.watchTransaction(intent.txHash)
153+
logger.info('Startup recovery: transaction recovered', {
154+
intentId: intent.id,
155+
txHash: intent.txHash,
156+
})
157+
} catch (err) {
158+
logger.error('Startup recovery: failed to recover transaction', {
159+
intentId: intent.id,
160+
txHash: intent.txHash,
161+
err,
162+
})
163+
}
164+
}),
165+
)
166+
}
167+
125168
const start = () => {
126169
logger.info('Starting payment manager')
170+
171+
// Run the recovery sweep asynchronously so it does not block startup.
172+
// Errors inside the sweep are caught per-intent and logged individually.
173+
safeCallback(paymentManager._recoverOrphanedTransactions)()
174+
127175
checkInterval = setInterval(
128176
safeCallback(paymentManager._checkConfirmedIntents),
129177
config.paymentManager.checkInterval,
@@ -154,6 +202,7 @@ export const paymentManager = {
154202
stop,
155203
_onLogs: onLogs,
156204
_checkConfirmedIntents: _checkConfirmedIntents,
205+
_recoverOrphanedTransactions: _recoverOrphanedTransactions,
157206
_viemClient: viemClient,
158207
_parseEventLogs: parseEventLogs,
159208
}

0 commit comments

Comments
 (0)