Skip to content

Commit c057e86

Browse files
authored
Buy credits (#506)
* Create Auto-Drive Treasury Contracts (Buy Credits PR #1) (#485) * Create auto drive treasury contracts * Refactor AutoDriveTreasury contract to use msg.sender instead of tx.origin for fund transfers * Add receive function to CounterTest contract for handling incoming Ether * Remove references to Counter contract * Implement treasury target instead of withdrawing functionality * Add Pausable functionality to AutoDriveCreditsReceiver contract * Add AutoDriveCreditsReceiver contract with deposit and treasury management functionality * Rename Deposit event to IntentPaymentReceived in AutoDriveCreditsReceiver contract for clarity * Move contracts docs to package root * Add treasure address to SweptToTreasury event * Rename deposit function to payIntent in AutoDriveCreditsReceiver contract and add validation for positive payment amounts. * Init contract state in constructor * Update AutoDriveCreditsReceiver tests to include treasury address in constructor and remove redundant initializations * Add minimum balance feature to AutoDriveCreditsReceiver contract and update tests accordingly * Refactor error handling in AutoDriveCreditsReceiver contract to use custom errors for validation checks and update tests accordingly * Make contract doc the package README * Add treasury receivable validation * Rename testDeposit function to testPayIntent in AutoDriveCreditsReceiver test for clarity * Remove references to deposit * Refactor constructor in AutoDriveCreditsReceiver to use setTreasury function for treasury initialization * Update AutoDriveCreditsReceiver to set a default minimum balance and simplify constructor parameters; adjust tests accordingly. * Update readme with correct events & methods interface * Buy credits backend (Buy Credits PR #2) (#486) * feat: enable admins to update granularity * feat: add intent model with schema validation and export in user module * feat: implement intents management with creation, updating, and confirmation logic in user module * feat: filter logs by contract address in payment manager * feat: normalize deposit amount in payment manager transaction confirmation * feat: add price retrieval endpoint and integrate price per MB configuration * feat: implement payments management with intent creation, confirmation, and credit allocation logic * fix: update intent credits retrieval and normalize deposit amount in payment manager * feat: add tests for adding credits to OneOff subscriptions and handling errors for non-OneOff subscriptions * feat: enhance intent management by adding price per MB handling in tests, database, and business logic * chore: remove console log * fix: correct error message for incomplete intent status * refactor: re-enable task manager and object mapping archiver event listeners in frontendWorker * feat: add price per MB to intent creation in database schema * refactor: remove intent expiration handling from API and backend logic * refactor: remove expires_at field from intents table and update related query * test: enhance folder upload tests with additional rabbitMock expectations * fix: handle errors when watching transactions in payment manager * refactor: update deposit event ABI and related payment handling in payment manager * refactor: rename deposit_amount to payment_amount in intents schema and related logic * refactor: remove intentId from task parameters and related intent handling * fix: update intent price per mb * refactor: update getIntent function to include user validation and adjust related intent retrieval logic * refactor: change payment_amount type from VARCHAR to numeric in intents table * feat: dynamic price fixing using current transaction byte price * feat: add premium percentage configuration and adjust price calculation in intents * refactor: rename premiumPct to priceMultiplier and update price calculation in intents * refactor: update priceMultiplier configuration to use CREDITS_PRICE_MULTIPLIER environment variable * feat: implement buy credits into frontend (Buy Credits PR #3) (#495) * feat: enable admins to update granularity * feat: add intent model with schema validation and export in user module * feat: implement intents management with creation, updating, and confirmation logic in user module * feat: filter logs by contract address in payment manager * feat: normalize deposit amount in payment manager transaction confirmation * feat: add price retrieval endpoint and integrate price per MB configuration * feat: implement payments management with intent creation, confirmation, and credit allocation logic * fix: update intent credits retrieval and normalize deposit amount in payment manager * feat: add tests for adding credits to OneOff subscriptions and handling errors for non-OneOff subscriptions * feat: enhance intent management by adding price per MB handling in tests, database, and business logic * chore: remove console log * fix: correct error message for incomplete intent status * refactor: re-enable task manager and object mapping archiver event listeners in frontendWorker * feat: add price per MB to intent creation in database schema * refactor: remove intent expiration handling from API and backend logic * refactor: remove expires_at field from intents table and update related query * test: enhance folder upload tests with additional rabbitMock expectations * fix: handle errors when watching transactions in payment manager * refactor: update deposit event ABI and related payment handling in payment manager * refactor: rename deposit_amount to payment_amount in intents schema and related logic * refactor: remove intentId from task parameters and related intent handling * fix: update intent price per mb * refactor: update getIntent function to include user validation and adjust related intent retrieval logic * refactor: change payment_amount type from VARCHAR to numeric in intents table * feat: dynamic price fixing using current transaction byte price * feat: add premium percentage configuration and adjust price calculation in intents * refactor: rename premiumPct to priceMultiplier and update price calculation in intents * refactor: update priceMultiplier configuration to use CREDITS_PRICE_MULTIPLIER environment variable * feat: purchase credits flow with new components, steps, and price handling * refactor: update price formatting functions to handle credits in MB and adjust related components * refactor: replace deposit hook with payment intent hook and update transaction handling in Step3_TransferTokens component * refactor: update price fetching logic to use kraken's API * Update local network configuration to use Chronos Testnet with new chain ID and native currency details * Enhance PurchaseStep3TransferTokens component by adding formatCreditsInMbAsValue function for improved credit formatting and updating payment intent handling. Added error logging for payment intent failures. * Update to new contract interface * Map BigInt to serializable values * Remove unused case for TAURUS in FilePreview component's network ID handling * Refactor price calculation in usePrices hook to use fixed-point arithmetic for improved precision in byte-to-value conversions. * Update PurchaseStep4Success component to remove unused network context and display storage size in MB instead of a fixed value. * Add BuyMoreCreditsButton component for purchasing credits with network context integration * Update native currency details in evm.ts to reflect new currency name and symbol * Update paymentReceiverContractsByNetworkId to include TODOs for removing TAURUS and deploying the MAINNET contract * Enhance AccountInformation and PurchaseStep2ConnectWallet components by improving byte formatting with decimal precision and adding a utility function for truncating numbers with decimals. * Extract to hook purchase credits confirmation * Extract to hook deposit awaiting logic and added to `usePayIntent` minimum confirmations constant * Refactor byte formatting in AccountInformation component to use truncateBytes utility for improved readability and consistency. * Buy credits feature flagging (#5) (#501) * Refactor former services param for features flags * refactor: restructure feature flags configuration - Move feature flags under 'flags' property for better organization - Add buyCredits feature flag with employeeOnly option - Add employeeDomains configuration - Move taskManagerMaxRetries to params section - Update feature flags structure for better maintainability * feat: add feature flags controller and business logic - Create featuresController with GET /features endpoint - Add FeatureFlagsUseCases with user-based feature flag logic - Implement employee domain checking for feature access - Add authentication-aware feature flag resolution * refactor: replace inline features endpoint with controller - Remove inline /features endpoints from download and frontend APIs - Add featuresController to both API servers - Centralize feature flags logic in dedicated controller * refactor: update worker to use new feature flags structure - Update frontendWorker to use config.featureFlags.flags - Move taskManagerMaxRetries reference to config.params - Maintain backward compatibility with existing functionality * feat: add frontend feature flags support - Add features state to user store - Add getFeatures API method with authentication - Implement feature flags fetching in SessionEnsurer - Support for user-specific feature flag resolution * feat: add conditional buy credits UI based on feature flags - Create AskForCreditsButton component for non-employee users - Update SideNavBar to conditionally show BuyMoreCreditsButton or AskForCreditsButton - Implement feature flag-based UI rendering for credit purchasing * feat: integrate feature flags fetching in SessionEnsurer - Add setFeatures to SessionEnsurer component - Fetch and store feature flags on component mount - Enable feature flag-based UI rendering throughout the app * feat: implement feature flag middleware and enhance features controller - Add featureFlagMiddleware to conditionally enable routes based on feature flags - Update intentsController to use feature flag for 'buyCredits' feature - Refactor featuresController to fetch feature flags and respond accordingly - Introduce getFeatureFlags function for user-specific feature flag retrieval * Rename employee to staff * Separate domain allowlist & usernames allowlist * fix: update feature flag retrieval to check for authorization header - Modify getFeatureFlags function to ensure user authentication by checking for the presence of the authorization header in the request. * Update staff naming in FeatureFlag model * fix: normalize username case in staff username validation * fix: normalize case for staff usernames and domains in configuration * fix: improve error handling in getFeatures API method * fix: update buyCredits feature flag condition to include staffOnly flag - Modify the condition to start the payment manager to check for both buyCredits and staffOnly feature flags. * Add mainnet's contract address (#505) * fix: errors during branch merge * Update .env.test to include EVM_CHAIN_ENDPOINT variable * Update .env.test to include EVM_CHAIN_CONTRACT_ADDRESS variable
1 parent bd76f9c commit c057e86

File tree

70 files changed

+5573
-2316
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

70 files changed

+5573
-2316
lines changed

.gitmodules

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@
22
path = submodules/files-gateway
33
url = https://github.com/autonomys/auto-files-gateway.git
44
branch = v1.0.4
5+
[submodule "packages/contracts/lib/forge-std"]
6+
path = packages/contracts/lib/forge-std
7+
url = https://github.com/foundry-rs/forge-std
8+
[submodule "packages/contracts/lib/openzeppelin-contracts"]
9+
path = packages/contracts/lib/openzeppelin-contracts
10+
url = https://github.com/openzeppelin/openzeppelin-contracts

apps/backend/.env.test

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@ AUTH_SERVICE_API_KEY=mock_value
66
AUTH_SERVICE_URL=https://example.com
77
CACHE_DIR='.test-cache'
88
FORBIDDEN_EXTENSIONS='exe,bin'
9-
DEBUG_LEVEL=info
9+
DEBUG_LEVEL=info
10+
STAFF_USERNAME_ALLOWLIST="none"
11+
STAFF_DOMAINS=autonomys.xyz
12+
EVM_CHAIN_ENDPOINT=http://example.org
13+
EVM_CHAIN_CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000

apps/backend/__tests__/e2e/uploads/folder.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,22 @@ describe('Folder Upload', () => {
300300
retriesLeft: expect.any(Number),
301301
})
302302
})
303+
304+
expect(rabbitMock).toHaveBeenCalledWith('task-manager', {
305+
id: 'publish-nodes',
306+
params: {
307+
nodes: [subfileCID],
308+
},
309+
retriesLeft: expect.any(Number),
310+
})
311+
312+
expect(rabbitMock).toHaveBeenCalledWith('task-manager', {
313+
id: 'publish-nodes',
314+
params: {
315+
nodes: [subfolderCid],
316+
},
317+
retriesLeft: expect.any(Number),
318+
})
303319
})
304320

305321
it('upload status should be updated on node publishing', async () => {

apps/backend/__tests__/e2e/users/credits.spec.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { InteractionType, UserWithOrganization } from '@auto-drive/models'
1+
import {
2+
InteractionType,
3+
AccountModel,
4+
UserWithOrganization,
5+
} from '@auto-drive/models'
26
import { PreconditionError } from '../../utils/error.js'
37
import { getDatabase } from '../../../src/infrastructure/drivers/pg.js'
48
import {
@@ -8,6 +12,9 @@ import {
812
} from '../../utils/mocks.js'
913
import { dbMigration } from '../../utils/dbMigrate.js'
1014
import { AccountsUseCases } from '../../../src/core/users/accounts.js'
15+
import { AuthManager } from '../../../src/infrastructure/services/auth/index.js'
16+
import { accountsRepository } from '../../../src/infrastructure/repositories/index.js'
17+
import { jest } from '@jest/globals'
1118

1219
describe('CreditsUseCases', () => {
1320
let mockUser: UserWithOrganization
@@ -67,4 +74,51 @@ describe('CreditsUseCases', () => {
6774

6875
expect(initialCredits - pendingCredits).toBe(Number(size).valueOf())
6976
})
77+
78+
it('should add credits to a OneOff subscription', async () => {
79+
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)
80+
const subscription = await AccountsUseCases.getOrCreateAccount(mockUser)
81+
// Ensure subscription is OneOff
82+
await accountsRepository.updateAccount(
83+
subscription.id,
84+
AccountModel.OneOff,
85+
subscription.uploadLimit,
86+
subscription.downloadLimit,
87+
)
88+
89+
const before = await AccountsUseCases.getAccountById(subscription.id)
90+
if (!before) throw new Error('Subscription not found')
91+
92+
const creditsToAdd = 123
93+
const result = await AccountsUseCases.addCreditsToAccount(
94+
mockUser.publicId,
95+
creditsToAdd,
96+
)
97+
98+
expect(result.isOk()).toBe(true)
99+
100+
const after = await AccountsUseCases.getAccountById(subscription.id)
101+
if (!after) throw new Error('Subscription not found')
102+
103+
expect(after.uploadLimit - before.uploadLimit).toBe(creditsToAdd)
104+
expect(after.model).toBe(AccountModel.OneOff)
105+
})
106+
107+
it('should fail to add credits if subscription is not OneOff', async () => {
108+
jest.spyOn(AuthManager, 'getUserFromPublicId').mockResolvedValue(mockUser)
109+
const subscription = await AccountsUseCases.getOrCreateAccount(mockUser)
110+
await accountsRepository.updateAccount(
111+
subscription.id,
112+
AccountModel.Monthly,
113+
subscription.uploadLimit,
114+
subscription.downloadLimit,
115+
)
116+
117+
const result = await AccountsUseCases.addCreditsToAccount(
118+
mockUser.publicId,
119+
10,
120+
)
121+
122+
expect(result.isErr()).toBe(true)
123+
})
70124
})
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { jest } from '@jest/globals'
2+
import { IntentsUseCases } from '../../../src/core/users/intents.js'
3+
import { intentsRepository } from '../../../src/infrastructure/repositories/users/intents.js'
4+
import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js'
5+
import { AccountsUseCases } from '../../../src/core/users/accounts.js'
6+
import { ForbiddenError } from '../../../src/errors/index.js'
7+
import { IntentStatus, type Intent, type User } from '@auto-drive/models'
8+
import { ok } from 'neverthrow'
9+
10+
describe('IntentsUseCases', () => {
11+
const now = new Date()
12+
const user: User = {
13+
id: 'user-id',
14+
publicId: 'pub-1',
15+
walletAddress: '0xabc',
16+
createdAt: now,
17+
updatedAt: now,
18+
authProvider: 'github',
19+
} as unknown as User
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks()
23+
jest.spyOn(IntentsUseCases, 'getPrice').mockResolvedValue({ price: 1 })
24+
})
25+
26+
afterEach(() => {
27+
jest.restoreAllMocks()
28+
})
29+
30+
it('createIntent should create PENDING intent for user', async () => {
31+
jest
32+
.spyOn(intentsRepository, 'createIntent')
33+
.mockImplementation(async (intent) => intent)
34+
35+
const intent = await IntentsUseCases.createIntent(user)
36+
37+
expect(intent.userPublicId).toBe(user.publicId)
38+
expect(intent.status).toBe(IntentStatus.PENDING)
39+
expect(intent.shannonsPerByte).toBe(1n)
40+
})
41+
42+
it('getIntent should return ok when found', async () => {
43+
const intent: Intent = {
44+
id: '0x1',
45+
userPublicId: user.publicId,
46+
status: IntentStatus.PENDING,
47+
shannonsPerByte: 1n,
48+
}
49+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
50+
51+
const result = await IntentsUseCases.getIntent(user, intent.id)
52+
expect(result.isOk()).toBe(true)
53+
expect(result._unsafeUnwrap().id).toBe(intent.id)
54+
})
55+
56+
it('getIntent should error when not found', async () => {
57+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(null)
58+
const result = await IntentsUseCases.getIntent(user, '0xnope')
59+
expect(result.isErr()).toBe(true)
60+
})
61+
62+
it('triggerWatchIntent should publish event and set txHash when user matches', async () => {
63+
const intent: Intent = {
64+
id: '0x2',
65+
userPublicId: user.publicId,
66+
status: IntentStatus.PENDING,
67+
shannonsPerByte: 1n,
68+
}
69+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
70+
const updateSpy = jest
71+
.spyOn(intentsRepository, 'updateIntent')
72+
.mockResolvedValue({ ...intent, txHash: '0xhash' })
73+
const publishSpy = jest
74+
.spyOn(EventRouter, 'publish')
75+
.mockImplementation(() => Promise.resolve())
76+
77+
const res = await IntentsUseCases.triggerWatchIntent({
78+
executor: user,
79+
txHash: '0xhash',
80+
intentId: intent.id,
81+
})
82+
83+
expect(res.isOk()).toBe(true)
84+
expect(publishSpy).toHaveBeenCalledWith(
85+
expect.objectContaining({ id: 'watch-intent-tx' }),
86+
)
87+
expect(updateSpy).toHaveBeenCalledWith(
88+
expect.objectContaining({ id: intent.id, txHash: '0xhash' }),
89+
)
90+
})
91+
92+
it('triggerWatchIntent should forbid when user mismatches', async () => {
93+
const intent: Intent = {
94+
id: '0x3',
95+
userPublicId: 'other',
96+
status: IntentStatus.PENDING,
97+
shannonsPerByte: 1n,
98+
}
99+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
100+
101+
const res = await IntentsUseCases.triggerWatchIntent({
102+
executor: user,
103+
txHash: '0xhash',
104+
intentId: intent.id,
105+
})
106+
107+
expect(res.isErr()).toBe(true)
108+
expect(res._unsafeUnwrapErr()).toBeInstanceOf(ForbiddenError)
109+
})
110+
111+
it('markIntentAsConfirmed should set status CONFIRMED and deposit amount', async () => {
112+
const intent: Intent = {
113+
id: '0x4',
114+
userPublicId: user.publicId,
115+
status: IntentStatus.PENDING,
116+
shannonsPerByte: 1n,
117+
}
118+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
119+
const updateSpy = jest
120+
.spyOn(intentsRepository, 'updateIntent')
121+
.mockResolvedValue({
122+
...intent,
123+
status: IntentStatus.CONFIRMED,
124+
paymentAmount: 10n,
125+
})
126+
127+
const res = await IntentsUseCases.markIntentAsConfirmed({
128+
intentId: intent.id,
129+
paymentAmount: 10n,
130+
})
131+
132+
expect(res.isOk()).toBe(true)
133+
expect(updateSpy).toHaveBeenCalledWith(
134+
expect.objectContaining({
135+
id: intent.id,
136+
status: IntentStatus.CONFIRMED,
137+
paymentAmount: 10n,
138+
}),
139+
)
140+
})
141+
142+
it('onConfirmedIntent should add credits and complete intent', async () => {
143+
const paymentAmount = 123n * 10n ** 12n // yields 123 credits when pricePerMB=1
144+
const intent: Intent = {
145+
id: '0x5',
146+
userPublicId: user.publicId,
147+
status: IntentStatus.CONFIRMED,
148+
paymentAmount,
149+
shannonsPerByte: 1n,
150+
}
151+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
152+
const addCreditsSpy = jest
153+
.spyOn(AccountsUseCases, 'addCreditsToAccount')
154+
.mockResolvedValue(ok())
155+
const updateSpy = jest
156+
.spyOn(intentsRepository, 'updateIntent')
157+
.mockResolvedValue({ ...intent, status: IntentStatus.COMPLETED })
158+
159+
const res = await IntentsUseCases.onConfirmedIntent(intent.id)
160+
161+
const credits = Number(paymentAmount / intent.shannonsPerByte)
162+
163+
expect(res.isOk()).toBe(true)
164+
expect(addCreditsSpy).toHaveBeenCalledWith(user.publicId, credits)
165+
expect(updateSpy).toHaveBeenCalledWith(
166+
expect.objectContaining({
167+
id: intent.id,
168+
status: IntentStatus.COMPLETED,
169+
}),
170+
)
171+
})
172+
173+
it('onConfirmedIntent should use intent.pricePerMB, not current config', async () => {
174+
const storedPrice = 2n
175+
// Choose deposit so that credits = 123 when divided by storedPrice
176+
const paymentAmount = 123n * BigInt(storedPrice) * 10n ** 12n
177+
const intent: Intent = {
178+
id: '0x8',
179+
userPublicId: user.publicId,
180+
status: IntentStatus.CONFIRMED,
181+
paymentAmount,
182+
shannonsPerByte: storedPrice,
183+
}
184+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
185+
const addCreditsSpy = jest
186+
.spyOn(AccountsUseCases, 'addCreditsToAccount')
187+
.mockResolvedValue(ok())
188+
const updateSpy = jest
189+
.spyOn(intentsRepository, 'updateIntent')
190+
.mockResolvedValue({ ...intent, status: IntentStatus.COMPLETED })
191+
192+
const res = await IntentsUseCases.onConfirmedIntent(intent.id)
193+
194+
expect(res.isOk()).toBe(true)
195+
const credits = Number(paymentAmount / intent.shannonsPerByte)
196+
expect(addCreditsSpy).toHaveBeenCalledWith(user.publicId, credits)
197+
expect(updateSpy).toHaveBeenCalledWith(
198+
expect.objectContaining({
199+
id: intent.id,
200+
status: IntentStatus.COMPLETED,
201+
}),
202+
)
203+
})
204+
205+
it('onConfirmedIntent should error when already completed', async () => {
206+
const intent: Intent = {
207+
id: '0x6',
208+
userPublicId: user.publicId,
209+
status: IntentStatus.COMPLETED,
210+
paymentAmount: 1n,
211+
shannonsPerByte: 1n,
212+
}
213+
jest.spyOn(intentsRepository, 'getById').mockResolvedValue(intent)
214+
215+
const res = await IntentsUseCases.onConfirmedIntent(intent.id)
216+
expect(res.isErr()).toBe(true)
217+
})
218+
219+
it('getConfirmedIntents should proxy repository', async () => {
220+
const intents: Intent[] = [
221+
{
222+
id: '0x7',
223+
userPublicId: user.publicId,
224+
status: IntentStatus.CONFIRMED,
225+
shannonsPerByte: 1n,
226+
},
227+
]
228+
jest.spyOn(intentsRepository, 'getByStatus').mockResolvedValue(intents)
229+
const res = await IntentsUseCases.getConfirmedIntents()
230+
expect(res).toEqual(intents)
231+
})
232+
})

0 commit comments

Comments
 (0)