Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ CELO_RPC_URL=https://forno.celo.org
CELO_VAULT_ADDRESS=0x
CELO_GOODID_ADDRESS=0xC361A6E67822a0EDc17D899227dd9FC50BD62F42
CELO_RESERVE_PRICE_ORACLE_ADDRESS=0x2fFBB49055d487DdBBb0C052Cd7c2a02A7971e41
CELO_RESERVE_EXCHANGE_ID=0xba77f5c7bb3317643c6d81d1ef3f9913561741d92095f88efa402faf2cbe9124
1 change: 1 addition & 0 deletions backend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.wrangler
30 changes: 26 additions & 4 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,46 @@ Cloudflare Worker for GoodDollar Celo-vault credit accounting and Celo → Base
- KV namespace binding: `ANTSEED_KV`
- Celo `CeloGdAntSeedVault` tx-log ingestion for G$ deposits and Superfluid stream updates
- Optional Base `AntseedBuyerOperator` bridge client that calls `depositForWithId(buyer, amount, id)`
- Cron trigger every minute for stream bonus settlement checks
- Cron trigger daily (UTC midnight) for Superfluid stream settlement checks

## Endpoints

- `GET /health`
- `GET /config/status`
- `GET /v1/quote/gd-to-credit` — G$ → AI credits via oracle (no bonus; bonus applied at record time)
- `GET /v1/quote/credit-to-gd` — AI credits → G$ via oracle (no bonus)
- `GET /v1/accounts/:account/status` — dashboard: profile, linked `buyer`, operator consent, withdrawable, outstanding
- `GET /v1/accounts/:account/credit`
- `GET /v1/accounts/:account/outstanding`
- `POST /v1/accounts/:account/withdraw`
- `GET /v1/requests/:requestId`
- `GET /v1/accounts/:account/transactions` — paginated `gdCredits` history (`status`, `limit`, `cursor`)
- `GET /v1/accounts/:account/operator` — buyer operator consent status (`?buyer=` optional, defaults to account)
- `GET /v1/accounts/:account/operator/consent-payload` — EIP-712 typed data for operator consent (includes nonce)
- `POST /v1/accounts/:account/operator/accept` — submit buyer signature → `acceptBuyerOperator` on Base
- `GET /v1/accounts/:account/withdrawable` — on-chain `withdrawablePrincipal` (`?buyer=` optional)
- `GET /v1/accounts/:account/withdraw/payload` — EIP-712 typed data for withdraw (`amountMicroUsd`, `recipient`, optional `?buyer=`)
- `POST /v1/accounts/:account/withdraw` — buyer EIP-712 sig → `withdrawPrincipal` on Base
- `POST /v1/accounts/:account/stream-credits` — manual daily stream accrual
- `POST /v1/celo/events/record`
- `POST /v1/celo/streams/update`
- `POST /v1/channels/close`
- `POST /v1/channels/withdraw`

`POST /v1/celo/events/record` accepts either:
- `{ "txHash": "0x..." }`
- `{ "account": "0x...", "fromBlock": "0x...", "toBlock": "latest" }`

`POST /v1/accounts/:account/operator/accept` body:
- `{ "buyerSig": "0x...", "buyerAddress": "0x..." }` — `buyerAddress` optional (defaults to account)

`POST /v1/accounts/:account/withdraw` body:
- `{ "buyerAddress": "0x...", "amountMicroUsd": "1000000", "recipient": "0x...", "timestamp": 1700000000, "buyerSig": "0x..." }`

## Widget flow (no Base chain details in UI)

1. `GET /v1/accounts/:account/status` — check `operator.operatorAccepted`
2. If false: `GET .../operator/consent-payload` → wallet signs `typedData` → `POST .../operator/accept`
3. Celo deposit → `POST /v1/celo/events/record`
4. Withdraw: `GET .../withdraw/payload?amountMicroUsd=...&recipient=0x...` → sign → `POST .../withdraw`

## Setup

```bash
Expand Down
246 changes: 244 additions & 2 deletions backend/src/antseed-funding-vault.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,32 @@
import { ethers } from "ethers";
import { RuntimeConfig } from "./env.js";
import { buildSetOperatorPayload } from "./operator-auth.js";
import type { Eip712SigningPayload } from "./operator-auth.js";

const FUNDING_VAULT_ABI = [
"function registry() view returns (address)",
"function depositFor(address buyer, uint256 principal, uint256 bonus)",
"function depositForWithId(address buyer, uint256 principal, uint256 bonus, string id)",
"function acceptBuyerOperator(address buyer, uint256 nonce, bytes buyerSig)",
"function requestClose(bytes32 channelId)",
"function withdrawChannel(bytes32 channelId)",
"function withdrawPrincipal(address buyer, uint256 amount, address recipient, uint256 timestamp, bytes buyerSig)",
"function withdrawablePrincipal(address buyer) view returns (uint256)",
"function usedDepositIds(bytes32 id) view returns (bool)"
] as const;

const REGISTRY_ABI = [
"function deposits() view returns (address)"
] as const;

const DEPOSITS_ABI = [
"function getOperator(address buyer) view returns (address)",
"function domainSeparator() view returns (bytes32)",
"function eip712Domain() view returns (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)",
"function operatorNonces(address buyer) view returns (uint256)",
"function nonces(address buyer) view returns (uint256)"
] as const;

export type AntSeedFundingResult = {
enabled: boolean;
buyer: string;
Expand All @@ -16,9 +35,23 @@ export type AntSeedFundingResult = {
error?: string;
};

export type BuyerOperatorStatus = {
enabled: boolean;
account: string;
buyerAddress: string;
operatorAddress?: string;
currentOperator: string;
operatorAccepted: boolean;
consentNonce: string;
};

export class AntSeedFundingVaultClient {
readonly enabled: boolean;
private contract?: ethers.Contract;
private provider?: ethers.JsonRpcProvider;
private chainIdPromise?: Promise<number>;
private depositsAddressPromise?: Promise<string>;
private depositsDomainPromise?: Promise<{ name: string; version: string }>;

private toBytes32Id(id: string): string {
return ethers.id(id);
Expand All @@ -31,12 +64,167 @@ export class AntSeedFundingVaultClient {
cfg.ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY
);
if (this.enabled) {
const provider = new ethers.JsonRpcProvider(cfg.ANTSEED_FUNDING_RPC_URL);
const signer = new ethers.Wallet(cfg.ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY!, provider);
this.provider = new ethers.JsonRpcProvider(cfg.ANTSEED_FUNDING_RPC_URL);
const signer = new ethers.Wallet(cfg.ANTSEED_FUNDING_OPERATOR_PRIVATE_KEY!, this.provider);
this.contract = new ethers.Contract(cfg.ANTSEED_FUNDING_VAULT_ADDRESS!, FUNDING_VAULT_ABI, signer);
}
}

get vaultAddress(): string | undefined {
return this.cfg.ANTSEED_FUNDING_VAULT_ADDRESS;
}

async getChainId(): Promise<number> {
if (!this.provider) {
throw new Error("bridge not configured");
}
if (!this.chainIdPromise) {
this.chainIdPromise = this.provider.getNetwork().then((network) => Number(network.chainId));
}
return this.chainIdPromise;
}

private async getDepositsContract(): Promise<ethers.Contract> {
if (!this.provider) {
throw new Error("bridge not configured");
}
const depositsAddress = await this.getDepositsAddress();
return new ethers.Contract(depositsAddress, DEPOSITS_ABI, this.provider);
}

async getDepositsAddress(): Promise<string> {
if (!this.contract) {
throw new Error("bridge not configured");
}
if (!this.depositsAddressPromise) {
this.depositsAddressPromise = (async () => {
const registryAddress = await this.contract!.registry();
const registry = new ethers.Contract(registryAddress, REGISTRY_ABI, this.provider!);
const depositsAddress = await registry.deposits();
return String(depositsAddress).toLowerCase();
})();
}
return this.depositsAddressPromise;
}

private async getDepositsEip712Domain(): Promise<{ name: string; version: string }> {
if (!this.depositsDomainPromise) {
this.depositsDomainPromise = (async () => {
const deposits = await this.getDepositsContract();
try {
const domain = await deposits.eip712Domain();
return {
name: String(domain.name),
version: String(domain.version)
};
} catch {
return { name: "AntseedDeposits", version: "1" };
}
})();
}
return this.depositsDomainPromise;
}

async getOperatorNonce(buyer: string): Promise<bigint> {
const deposits = await this.getDepositsContract();
const normalizedBuyer = buyer.toLowerCase();
try {
const nonce = await deposits.operatorNonces(normalizedBuyer);
return BigInt(nonce.toString());
} catch {
const nonce = await deposits.nonces(normalizedBuyer);
return BigInt(nonce.toString());
}
}

async getBuyerOperatorStatus(account: string, buyerAddress?: string): Promise<BuyerOperatorStatus> {
const buyer = (buyerAddress ?? account).toLowerCase();
const accountNormalized = account.toLowerCase();
if (!this.contract || !this.vaultAddress) {
return {
enabled: false,
account: accountNormalized,
buyerAddress: buyer,
currentOperator: ethers.ZeroAddress,
operatorAccepted: false,
consentNonce: "0"
};
}

const deposits = await this.getDepositsContract();
const currentOperator = String(await deposits.getOperator(buyer)).toLowerCase();
const operatorAddress = this.vaultAddress.toLowerCase();
const consentNonce = await this.getOperatorNonce(buyer);

return {
enabled: true,
account: accountNormalized,
buyerAddress: buyer,
operatorAddress,
currentOperator,
operatorAccepted: currentOperator === operatorAddress,
consentNonce: consentNonce.toString()
};
}

async getDepositsSigningDomain(): Promise<{ name: string; version: string }> {
return this.getDepositsEip712Domain();
}

async buildOperatorConsentPayload(account: string, buyerAddress?: string): Promise<{
enabled: boolean;
account: string;
buyerAddress: string;
typedData?: Eip712SigningPayload;
}> {
const status = await this.getBuyerOperatorStatus(account, buyerAddress);
if (!status.enabled || !status.operatorAddress) {
return {
enabled: false,
account: status.account,
buyerAddress: status.buyerAddress
};
}

const [chainId, depositsAddress, domain] = await Promise.all([
this.getChainId(),
this.getDepositsAddress(),
this.getDepositsEip712Domain()
]);

return {
enabled: true,
account: status.account,
buyerAddress: status.buyerAddress,
typedData: buildSetOperatorPayload(
chainId,
depositsAddress,
status.operatorAddress,
BigInt(status.consentNonce),
domain
)
};
}

async acceptBuyerOperator(
buyer: string,
nonce: bigint,
buyerSig: string
): Promise<{ enabled: boolean; buyer: string; nonce: string; txHash?: string }> {
const normalizedBuyer = buyer.toLowerCase();
if (!this.contract) {
return { enabled: false, buyer: normalizedBuyer, nonce: nonce.toString() };
}
const tx = await this.contract.acceptBuyerOperator(normalizedBuyer, nonce, buyerSig);
const receipt = await tx.wait();
return {
enabled: true,
buyer: normalizedBuyer,
nonce: nonce.toString(),
txHash: receipt?.hash
};
}

async depositForBuyer(buyer: string, principalMicroUsd: bigint, bonusMicroUsd: bigint): Promise<AntSeedFundingResult> {
const total = principalMicroUsd + bonusMicroUsd;
if (!this.contract) return { enabled: false, buyer, amountMicroUsd: total.toString() };
Expand Down Expand Up @@ -75,4 +263,58 @@ export class AntSeedFundingVaultClient {
const receipt = await tx.wait();
return { enabled: true, txHash: receipt?.hash };
}

async withdrawChannel(channelId: string): Promise<{ enabled: boolean; txHash?: string }> {
if (!this.contract) return { enabled: false };
const tx = await this.contract.withdrawChannel(channelId);
const receipt = await tx.wait();
return { enabled: true, txHash: receipt?.hash };
}

async getWithdrawablePrincipal(buyer: string): Promise<{ enabled: boolean; buyer: string; withdrawableMicroUsd: string }> {
const normalizedBuyer = buyer.toLowerCase();
if (!this.contract) {
return { enabled: false, buyer: normalizedBuyer, withdrawableMicroUsd: "0" };
}
const amount = await this.contract.withdrawablePrincipal(normalizedBuyer);
return {
enabled: true,
buyer: normalizedBuyer,
withdrawableMicroUsd: amount.toString()
};
}

async withdrawPrincipal(
buyer: string,
amountMicroUsd: bigint,
recipient: string,
timestamp: bigint,
buyerSig: string
): Promise<{ enabled: boolean; buyer: string; amountMicroUsd: string; recipient: string; txHash?: string }> {
const normalizedBuyer = buyer.toLowerCase();
const normalizedRecipient = recipient.toLowerCase();
if (!this.contract) {
return {
enabled: false,
buyer: normalizedBuyer,
amountMicroUsd: amountMicroUsd.toString(),
recipient: normalizedRecipient
};
}
const tx = await this.contract.withdrawPrincipal(
normalizedBuyer,
amountMicroUsd,
normalizedRecipient,
timestamp,
buyerSig
);
const receipt = await tx.wait();
return {
enabled: true,
buyer: normalizedBuyer,
amountMicroUsd: amountMicroUsd.toString(),
recipient: normalizedRecipient,
txHash: receipt?.hash
};
}
}
3 changes: 1 addition & 2 deletions backend/src/celo-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export async function fetchCeloVaultEventsForAccount(

export async function fetchCurrentGdMicroUsdPerToken(cfg: RuntimeConfig): Promise<bigint> {
if (!cfg.CELO_RPC_URL || !cfg.CELO_RESERVE_PRICE_ORACLE_ADDRESS) return cfg.GD_MICRO_USD_PER_TOKEN;
const data = RESERVE_ORACLE_ABI.encodeFunctionData("currentPrice", []);
const data = RESERVE_ORACLE_ABI.encodeFunctionData("currentPrice", [cfg.CELO_RESERVE_EXCHANGE_ID]);
const result = await rpc<string>(cfg.CELO_RPC_URL, "eth_call", [{ to: cfg.CELO_RESERVE_PRICE_ORACLE_ADDRESS, data }, "latest"]);
if (!result || result === "0x") return cfg.GD_MICRO_USD_PER_TOKEN;
const [price] = RESERVE_ORACLE_ABI.decodeFunctionResult("currentPrice", result);
Expand All @@ -86,7 +86,6 @@ export async function fetchCurrentGdMicroUsdPerToken(cfg: RuntimeConfig): Promis
return reservePrice;
}


return cfg.GD_MICRO_USD_PER_TOKEN;
}

Expand Down
9 changes: 7 additions & 2 deletions backend/src/credit-bonus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type CreditBonusResult = {
const REGULAR_BONUS_BPS = 1_000n; // +10%
const STREAMING_BONUS_BPS = 2_000n; // +20% for streaming sources
const BPS = 10_000n;

const WEI_PER_GD = 1_000_000_000_000_000_000n;

export function calculateCreditWithBonus(gdAmountWei: bigint, source: GdCreditEntry["source"], isVerified: boolean, gdPrice: bigint): CreditBonusResult {

Expand All @@ -33,7 +33,12 @@ export function calculateCreditWithBonus(gdAmountWei: bigint, source: GdCreditEn
}

export function gdWeiToMicroUsd(gdAmountWei: bigint, gdMicroUsdPerToken: bigint): bigint {
return (gdAmountWei * gdMicroUsdPerToken) / 1_000_000_000_000_000_000n;
return (gdAmountWei * gdMicroUsdPerToken) / WEI_PER_GD;
}

export function microUsdToGdWei(microUsd: bigint, gdMicroUsdPerToken: bigint): bigint {
if (gdMicroUsdPerToken <= 0n) return 0n;
return (microUsd * WEI_PER_GD) / gdMicroUsdPerToken;
}

export function monthlyStreamMicroUsd(flowRateWeiPerSecond: bigint, gdMicroUsdPerToken: bigint): bigint {
Expand Down
Loading
Loading