Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/223 billing basics #224

Merged
merged 14 commits into from
Feb 3, 2025
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
Loading