Skip to content

Commit

Permalink
Feature/223 billing basics (#224)
Browse files Browse the repository at this point in the history
* Untested basic billing

* Implement basic billing

* Start of VDA deposit page

* Add alchemy manager to get VDA price and fetch ERC20 token transfer details

* Support depositing crypto

* Support enabling / disabling billing

* Support caching VDA price

* Support payer in the auth request. Support billing with requests, with unit tests.

* Minor bug fixes

* Support getting and registering an account.  Support fetching vda price. Deposit bug fixes. Handle missing transaction nicely.

* Add DID to GET /auth/token response.

* Replace + with _ in API key to avoid query string issues. Improve invalid scope request error message.

* Fix datastore routes

* Support outputing credits in scope endpoint
  • Loading branch information
tahpot authored Feb 3, 2025
1 parent 72fbe46 commit c4c2839
Show file tree
Hide file tree
Showing 27 changed files with 1,620 additions and 103 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@verida/vda-sbt-client": "^4.4.1",
"@verida/verifiable-credentials": "^4.4.0",
"@verida/web3": "^4.4.0",
"alchemy-sdk": "^3.5.1",
"aws-serverless-express": "^3.4.0",
"axios": "^1.7.2",
"body-parser": "^1.19.0",
Expand Down
104 changes: 101 additions & 3 deletions src/api/rest/v1/app/controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
import { Request, Response } from "express";
import UsageManager from "../../../../services/usage/manager"
import BillingManager from "../../../../services/billing/manager"
import AlchemyManager from "../../../../services/billing/alchemy"
import Alchemy from "../../../../services/billing/alchemy"
import { BillingAccountType, BillingTxnType } from "../../../../services/billing/interfaces";
import { Utils } from "alchemy-sdk";

function bigintReplacer(key: string, value: any) {
if (typeof value === 'bigint') {
return value.toString(); // Convert BigInt to string
}
return value;
}

function serialize(data: any): string {
return JSON.parse(JSON.stringify(data, bigintReplacer))
}

export class AppController {

public async getAccount(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection

const account = await BillingManager.getAccount(did)

if (!account) {
return res.status(404).json({
success: false
})
}

return res.json({
account
})
}

public async register(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection

return res.json({
success: await BillingManager.registerAccount(did, BillingAccountType.APP)
})
}

public async requests(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection

Expand All @@ -14,21 +54,79 @@ export class AppController {
public async accountCount(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection

return res.json({
return res.json(serialize({
count: await UsageManager.getAccountCount(did)
})
}))
}

public async usage(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection
const startDateTime = req.params.start ? req.params.start.toString() : undefined
const endDateTime = req.params.end ? req.params.end.toString() : undefined

return res.json(serialize({
usage: await UsageManager.getUsageStats(did, startDateTime, endDateTime)
}))
}

public async balance(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection

const balance = await BillingManager.getBalance(did)
return res.json(serialize({
balance
}))
}

public async vdaPrice(req: Request, res: Response) {
const vdaPrice = await AlchemyManager.getVDAPrice()
return res.json({
price: vdaPrice
})
}

public async deposits(req: Request, res: Response) {
const { did } = req.veridaNetworkConnection

return res.json(serialize({
deposits: await BillingManager.getDeposits(did)
}))
}

public async depositCrypto(req: Request, res: Response) {
if (!BillingManager.isEnabled) {
return res.status(500).send({
"error": "Billing is disabled"
})
}

const { did, network } = req.veridaNetworkConnection
const { txnId, fromAddress, amount, signature } = req.body
const didDocument = await network.didClient.get(did)

try {
await BillingManager.verifyCryptoDeposit(didDocument, txnId, fromAddress, amount, signature)


await BillingManager.deposit({
did,
txnId,
tokens: Utils.parseUnits(amount.toString(), 18).toString(),
description: 'Account deposit',
txnType: BillingTxnType.CRYPTO
})
} catch (err) {
return res.status(400).send({
"error": err.message
})
}

return res.json({
stats: await UsageManager.getUsageStats(did, startDateTime, endDateTime)
success: true
})
}


}

const controller = new AppController()
Expand Down
15 changes: 10 additions & 5 deletions src/api/rest/v1/app/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@ import Controller from './controller'
import auth from "../../../../middleware/auth";

const router = express.Router()
const sessionAuth = auth({
sessionRequired: true,
const appAuth = auth({
options: {
// App DID's don't need to be whitelisted
ignoreAccessCheck: true
}
})

router.get('/requests', sessionAuth, Controller.requests)
router.get('/account-count', sessionAuth, Controller.accountCount)
router.get('/usage', sessionAuth, Controller.usage)
router.get('/account', appAuth, Controller.getAccount)
router.get('/register', appAuth, Controller.register)
router.get('/requests', appAuth, Controller.requests)
router.get('/account-count', appAuth, Controller.accountCount)
router.get('/usage', appAuth, Controller.usage)
router.get('/balance', appAuth, Controller.balance)
router.get('/vda-price', appAuth, Controller.vdaPrice)
router.get('/deposits', appAuth, Controller.deposits)
router.post('/deposit-crypto', appAuth, Controller.depositCrypto)

export default router
43 changes: 27 additions & 16 deletions src/api/rest/v1/auth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ import { Resolver } from 'did-resolver';
import { VeridaDocInterface, IContext, Network } from '@verida/types';
import { AuthRequest } from "./interfaces"
import CONFIG from "../../../../config"
import { Utils } from '../../../../utils';
import { BillingAccountType } from '../../../../services/billing/interfaces';

const vdaDidResolver = getResolver()
const didResolver = new Resolver(vdaDidResolver)

const VAULT_CONTEXT_NAME = `Verida: Vault`

const PAYER_TYPES = [
BillingAccountType.APP,
BillingAccountType.USER
]

export class AuthClient {
protected did: string
protected didDocument?: DIDDocument
Expand All @@ -32,16 +39,16 @@ export class AuthClient {
await this.init()
const account = context.getAccount()
const signerDid = await account.did()
const userKeyring = await account.keyring(VAULT_CONTEXT_NAME)

const authRequest: AuthRequest = JSON.parse(authRequestString)
// Ensure `revoke-tokens` scope is never set
if (authRequest.scopes && authRequest.scopes.length) {
authRequest.scopes = authRequest.scopes.filter(str => str !== 'access-tokens')
}

// Verify the authRequest is signed by this.did
// console.log('Verify the authRequest is signed by this.did')
const isValidUserSig = this.didDocument.verifyContextSignature(authRequestString, <Network> CONFIG.verida.environment, `Verida: Vault`, userSig, false)
// Verify the authRequest is signed by the user
const isValidUserSig = await userKeyring.verifySig(authRequestString, userSig)
if (!isValidUserSig) {
throw new Error(`Invalid user account signature on the auth request`)
}
Expand All @@ -50,20 +57,24 @@ export class AuthClient {
throw new Error(`Invalid user account signer on the auth request`)
}

// Get third party application DID Document
if (authRequest.appDID) {
const response = await didResolver.resolve(authRequest.appDID)
const appDidDocument = new DIDDocument(<VeridaDocInterface> response.didDocument!)

// @todo: Verify DIDDocument has serviceEndpoint of type `VeridaOAuthServer` that matches redirectUrl
const didDoc = appDidDocument.export()
// console.log(didDoc)
let serverFound = false
for (const service of didDoc.service) {
// console.log(service)
}
if ([BillingAccountType.APP, BillingAccountType.USER].indexOf(authRequest.payer) === -1) {
throw new Error(`Invalid payer (${authRequest.payer}) or mis-match with auth request`)
}

// Get third party application DID Document
// if (authRequest.appDID) {
// const response = await didResolver.resolve(authRequest.appDID)
// const appDidDocument = new DIDDocument(<VeridaDocInterface> response.didDocument!)

// // @todo: Verify DIDDocument has serviceEndpoint of type `VeridaOAuthServer` that matches redirectUrl
// const didDoc = appDidDocument.export()
// // console.log(didDoc)
// let serverFound = false
// for (const service of didDoc.service) {
// // console.log(service)
// }
// }

// Verify clientSecret timestamp is within minutes of current timestamp
const timeoutMins = CONFIG.verida.OAuthRequestTimeoutMins
const timeoutMs = timeoutMins * 60 * 1000; // 2 minutes in milliseconds
Expand Down
20 changes: 16 additions & 4 deletions src/api/rest/v1/auth/controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Request, Response } from "express";
import { Utils } from "../../../../utils";
import { AuthClient } from "./client";
import { BillingAccountType } from "../../../../services/billing/interfaces";
import AuthServer from "./server";
import { AuthUser } from "./user";
import { AuthToken, ScopeType } from "./interfaces";
import UsageManager from "../../../../services/usage/manager"
import SCOPES, { DATABASE_LOOKUP, DATASTORE_LOOKUP, expandScopes, isKnownSchema } from "./scopes"
import SCOPES, { DATABASE_LOOKUP, expandScopes, isKnownSchema } from "./scopes"
import CONFIG from "../../../../config"
import axios from "axios";

type ResolvedScopePermission = ("r" | "w" | "d")
Expand All @@ -18,6 +20,7 @@ interface ResolvedScope {
description?: string
uri?: string
knownSchema?: boolean
credits: number
}

const SCHEMA_CACHE: Record<string, {
Expand Down Expand Up @@ -130,12 +133,15 @@ export class AuthController {
const authToken = decodeURIComponent(req.query.tokenId.toString())
req.headers.authorization = `Bearer ${authToken}`

const { context, tokenId } = await Utils.getNetworkConnectionFromRequest(req, { ignoreScopeCheck: true })
const { context, tokenId, did } = await Utils.getNetworkConnectionFromRequest(req, { ignoreScopeCheck: true })

const authUser = new AuthUser(context)
const authTokenObj: AuthToken = await authUser.getAuthToken(tokenId)

res.json({ token: authTokenObj });
res.json({ token: {
did,
...authTokenObj
} });
} catch (err) {
if (err.message.match('Invalid token')) {
return res.status(403).json({ error: err.message })
Expand Down Expand Up @@ -189,7 +195,8 @@ export class AuthController {
const authToken = await AuthServer.createAuthToken({
session: sessionString,
scopes,
userDID
userDID,
payer: BillingAccountType.USER
}, authUser, sessionString)

res.send({ token: authToken });
Expand Down Expand Up @@ -222,6 +229,8 @@ export class AuthController {
const scopeType = <ScopeType> scopeParts[0]
let scopeId = `${scopeType}`

const credits = CONFIG.verida.billing.routeCredits[scope] ? CONFIG.verida.billing.routeCredits[scope] : CONFIG.verida.billing.defaultCredits

switch (scopeType) {
case ScopeType.API:
if (!SCOPES[scope]) {
Expand All @@ -236,6 +245,7 @@ export class AuthController {
progressScopes[scopeId] = {
type: scopeType,
name: apiGrant,
credits,
description: SCOPES[scope].userNote
}

Expand All @@ -261,6 +271,7 @@ export class AuthController {
type: scopeType,
name: dbGrant,
permissions,
credits,
description: DATABASE_LOOKUP[`db:${dbGrant}`]?.description
}
}
Expand Down Expand Up @@ -311,6 +322,7 @@ export class AuthController {
name: schemaTitle,
namePlural: schemaTitlePlural,
uri: schemaUrl,
credits,
knownSchema: isKnownSchema(schemaUrl)
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/api/rest/v1/auth/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { BillingAccountType } from "../../../../services/billing/interfaces"

export interface AuthRequest {
appDID?: string
userDID: string
scopes: string[]
payer?: BillingAccountType
timestamp: number
}

Expand All @@ -16,6 +18,7 @@ export interface AuthToken {
export interface APIKeyData {
session: string,
scopes: string[]
payer: BillingAccountType
userDID: string
appDID?: string
}
Expand All @@ -30,4 +33,5 @@ export interface Scope {
type: ScopeType
description: string
userNote?: string
credits?: number
}
21 changes: 20 additions & 1 deletion src/api/rest/v1/auth/scopes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Scope, ScopeType } from "./interfaces"
import CONFIG from "../../../../config"

const DEFAULT_CREDITS = CONFIG.verida.billing.defaultCredits

interface ScopeInfo {
uri?: string
Expand Down Expand Up @@ -266,7 +269,6 @@ const SCOPES: Record<string, Scope> = {
description: "Run a LLM prompt to generate a profile based on user data",
userNote: `Run a LLM prompt to generate a profile based on user data`
},


"api:search-chat-threads": {
type: ScopeType.API,
Expand Down Expand Up @@ -316,4 +318,21 @@ for (const datastoreId in DATASTORE_LOOKUP) {
}
}

// Add credit info to API scopes
for (const scope in SCOPES) {
if (SCOPES[scope].type == ScopeType.API) {
if (CONFIG.verida.billing.routeCredits[scope]) {
SCOPES[scope] = {
...SCOPES[scope],
credits: CONFIG.verida.billing.routeCredits[scope]
}
} else {
SCOPES[scope] = {
...SCOPES[scope],
credits: DEFAULT_CREDITS
}
}
}
}

export default SCOPES
Loading

0 comments on commit c4c2839

Please sign in to comment.