Skip to content
Merged
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
31 changes: 15 additions & 16 deletions .agents/skills/zoneless-marketplace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,13 @@ the human explicitly asks for that additional work.
Use plain language whenever the human must act. Briefly explain that USDC is a
digital dollar, Solana is the network carrying it, SOL pays the small network
fee, a wallet address is safe to share, and a wallet secret key authorizes
spending and must remain private. Explain that devnet/test mode uses free,
worthless test funds on a network separate from mainnet/live mode, and that a
faucet is a website that supplies those test funds. Do not use terms such as
gas, mint, token account, cluster, or airdrop without explaining them.
spending and must remain private. Explain that test mode uses fake USDC so they
can onboard sellers and run payouts without a wallet, a faucet, or the live
Solana network. Live mode is when real USDC moves on Solana. Do not use terms
such as gas, mint, token account, cluster, or airdrop without explaining them.

Give concrete instructions: name the network and asset, say where to copy the
public wallet address, link the relevant guide or faucet, and distinguish test
funds from real funds. Never assume prior wallet or blockchain experience.
Give concrete instructions: distinguish test funds from real funds. Never
assume prior wallet or blockchain experience.

## Read documentation just in time

Expand Down Expand Up @@ -195,7 +194,8 @@ Use the payout helper that matches the existing worker:
`payouts.broadcast(...)` when the marketplace must process an explicitly
claimed payout ID.

The SDK already builds and signs Solana transactions. Do not install
The SDK already builds and signs Solana transactions. In simulated test mode
it passes dummy `sim_tx` payloads through without signing. Do not install
`@solana/web3.js`, `@solana/spl-token`, `bs58`, or add mint, cluster, RPC, token
account, or platform-account configuration to a Node application. Those are
Zoneless implementation details. The marketplace app needs only the relevant
Expand Down Expand Up @@ -351,14 +351,13 @@ Report:
- required environment-variable names, never values;
- tests run and any unverified behavior;
- the test-mode onboarding path;
- that before an end-to-end test payout, the human must fund the test
platform's public wallet address (the `wallet_public_key` returned by setup or
the address shown under **Balance**) with devnet SOL from
`https://faucet.solana.com/` and test USDC from
`https://faucet.circle.com/`, selecting **USDC** and **Solana Devnet** at the
Circle faucet; link
`https://zoneless.com/docs/local-development.md` for the complete steps and
warn never to send real SOL or USDC to devnet;
- that a test payout still uses the live two-step flow: create stays
pending, then `processAll` / build and broadcast marks it paid with
simulated USDC. No wallet, faucet, or Devnet is required — broadcast
the unsigned dummy transaction. Use **Add test USDC**
on the dashboard Balance page (or
`POST /v1/test_helpers/treasury/topups`) if the platform ledger needs a
balance first;
- where the human must configure the API key and webhook secret;
- the exact live-mode promotion sequence, including the bound live profile
name, `https://api.zoneless.com`, deployment secret changes, live webhook
Expand Down
22 changes: 11 additions & 11 deletions .agents/skills/zoneless-payments/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ explicitly asks for that additional work.
Use plain language whenever the human must act. Briefly explain that USDC is a
digital dollar and Solana is the network carrying it, that a wallet address is
safe to share, and that a wallet secret key authorizes spending and must remain
private. Explain that test mode uses free, worthless test funds on a network
separate from live mode, and that a faucet is a website that supplies those
test funds. Do not use terms such as gas, mint, token account, cluster, or
airdrop without explaining them.
private. Explain that test mode uses fake USDC so they can try checkout
without a wallet, a faucet, or the live Solana network. Live mode is when
real USDC moves on Solana. Do not use terms such as gas, mint, token account,
cluster, or airdrop without explaining them.

The merchant does not need to hold, fund, or manage any crypto to receive
subscription payments. Money moves from the customer's wallet to the platform
Expand Down Expand Up @@ -365,9 +365,9 @@ Run the project's formatter, focused tests, linter, type checker, and build.
Fix regressions introduced by the integration.

Open a test Checkout Session and verify that the hosted page shows the expected
product, amount, cadence, and return URLs. When a test wallet is available,
complete one supervised test subscription and verify the resulting entitlement.
Do not describe unexecuted end-to-end behavior as tested.
product, amount, cadence, and return URLs. Complete one test subscription with
the simulated wallet (or the test_helpers complete endpoint) and verify the
resulting entitlement. Do not describe unexecuted end-to-end behavior as tested.

## Human handoff

Expand All @@ -381,10 +381,10 @@ Report:
`ZONELESS_API_URL`, and `ZONELESS_WEBHOOK_SECRET`), never values;
- checks run, distinguishing static checks, unit tests, hosted-checkout
verification, and completed end-to-end payments;
- that to test a subscription end to end, the human needs a Solana wallet
holding test USDC, available free from `https://faucet.circle.com/` by
selecting **USDC** and **Solana Devnet**, and that they must never send real
funds to a test network;
- that to test a subscription end to end, the human opens hosted checkout in
test mode and approves the simulated wallet (no Phantom, faucet, or Devnet
switch). They can also complete a session with
`POST /v1/test_helpers/checkout/sessions/:id/complete`;
- that the customer approves the plan once at checkout and every later cycle is
collected automatically, so no further signing is required;
- where the human must configure the live API key, live API URL, and live
Expand Down
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,18 @@ SINGLE_TENANT=
# /v1/operator API. Implies multi-tenant mode (SINGLE_TENANT is ignored).
OPERATOR_API_KEY=

# Solana RPC endpoint
# Solana RPC endpoint (only needed when SETTLEMENT_RAIL=onchain)
# e.g. https://api.devnet.solana.com or a Helius/QuickNode URL
SOLANA_RPC_URL=

# LIVEMODE controls Solana network: false = devnet (test), true = mainnet (real USDC)
# LIVEMODE=true uses Solana mainnet (real USDC). Unset/false is test mode.
LIVEMODE=

# How test mode settles money: simulated (default, fake funds) or onchain
# (Solana Devnet — internal escape hatch). Live mode is always on-chain.
# SETTLEMENT_RAIL=simulated
# SETTLEMENT_RAIL=onchain

# Base58 secret key that owns on-chain subscription plans and signs pulls.
# Required to create recurring prices. Existing plans must keep the same owner.
# SUBSCRIPTION_OPERATOR_KEY=
Expand Down
9 changes: 7 additions & 2 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,18 @@ SINGLE_TENANT=true
# /v1/operator API. Implies multi-tenant mode (SINGLE_TENANT is ignored).
OPERATOR_API_KEY=

# Solana RPC endpoint
# Solana RPC endpoint (required for live / on-chain settlement)
# e.g. https://api.mainnet-beta.solana.com or a Helius/QuickNode URL
SOLANA_RPC_URL=

# LIVEMODE controls Solana network: true = mainnet (real USDC), false = devnet (test)
# LIVEMODE=true uses Solana mainnet (real USDC). Unset/false is test mode
# with simulated funds by default.
LIVEMODE=true

# Test mode settlement: simulated (default) or onchain (Solana Devnet).
# Invalid when LIVEMODE=true.
# SETTLEMENT_RAIL=onchain

# Base58 secret key that owns on-chain subscription plans and signs pulls.
# Required to create recurring prices. Must match the owner used when plans
# were first created.
Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ npx nx lint api --fix
npx nx lint web --fix
```

Test mode uses simulated funds by default (`SETTLEMENT_RAIL=simulated`). To
exercise Solana Devnet instead, set `LIVEMODE=false` and `SETTLEMENT_RAIL=onchain`.

## How to Contribute

### Reporting Bugs
Expand Down
7 changes: 4 additions & 3 deletions apps/api/src/__tests__/Payout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ jest.mock('../modules/AppConfig', () => ({
appSecret: 'test-secret',
})),
}));
jest.mock('../modules/chains/Solana', () => ({
Solana: jest.fn().mockImplementation(() => ({
jest.mock('../modules/chains/Settlement', () => ({
GetSettlement: () => ({
CheckWalletExists: jest.fn().mockResolvedValue(true),
GetUSDCBalance: jest.fn().mockResolvedValue(100),
GetSOLBalance: jest.fn().mockResolvedValue(1),
Expand All @@ -39,7 +39,8 @@ jest.mock('../modules/chains/Solana', () => ({
signature: 'sig123',
viewer_url: 'https://solscan.io/tx/sig123',
}),
})),
}),
IsSimulatedSettlement: jest.fn(() => false),
}));

describe('PayoutModule', () => {
Expand Down
79 changes: 79 additions & 0 deletions apps/api/src/__tests__/Settlement.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
SimulatedSettlement,
SimulatedSignature,
} from '../modules/chains/Settlement';

describe('SimulatedSettlement', () => {
const settlement = new SimulatedSettlement();

it('accepts a plausible wallet address', async () => {
await expect(
settlement.CheckWalletExists(
'D8VMZCmmTUUfhejNhNQKAmqvZCKfUq1qU6RqQKxQwXyX'
)
).resolves.toBe(true);
await expect(settlement.CheckWalletExists('short')).resolves.toBe(false);
});

it('verifies a simulated checkout signature', async () => {
const signature = SimulatedSignature('cs_z_1', 'Payer111');
const result = await settlement.VerifyCheckoutPayment(signature, {
merchantWalletAddress: 'Merchant111',
amountInCents: 1500,
checkoutSessionId: 'cs_z_1',
});
expect(result).toEqual({
verified: true,
amount_cents: 1500,
payer_address: 'Payer111',
});
});

it('rejects a non-simulated checkout signature', async () => {
const result = await settlement.VerifyCheckoutPayment('real_sig', {
merchantWalletAddress: 'Merchant111',
amountInCents: 1500,
checkoutSessionId: 'cs_z_1',
});
expect(result.verified).toBe(false);
});

it('skips subscription authority init so checkout is one approval', async () => {
await expect(
settlement.BuildInitSubscriptionAuthorityTransaction('Payer111')
).resolves.toBeNull();
});

it('collects a subscription without hitting a chain', async () => {
await expect(
settlement.CollectSubscriptionPayment({
subscriberWallet: 'Payer111',
planPda: 'sim_plan_price_z_1',
subscriptionPda: 'sim_sub_1',
amountCents: 2000,
})
).resolves.toEqual({
signature: 'sim_collect:Payer111',
alreadyCollected: false,
});
});

it('builds and broadcasts a dummy payout without a chain', async () => {
await expect(
settlement.BuildBatchPayoutTransaction('Sender111', [
{ destinationAddress: 'Dest111', amountInCents: 500 },
])
).resolves.toMatchObject({
unsigned_transaction: 'sim_tx',
last_valid_block_height: 1,
recipients_count: 1,
});
await expect(
settlement.BroadcastSignedTransaction('sim_tx')
).resolves.toEqual({
signature: 'sim_sig_payout',
status: 'paid',
viewer_url: '',
});
});
});
1 change: 1 addition & 0 deletions apps/api/src/__tests__/mocks/Solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class Solana {
GetSOLBalance = jest.fn().mockResolvedValue(1);
GetUSDCMintAddress = jest.fn().mockReturnValue('UsdcMint_test');
GetPlanOwnerPublicKey = jest.fn().mockReturnValue('PlanOwner111');
GetIncomingDeposits = jest.fn().mockResolvedValue([]);
GetIncomingUSDCDeposits = jest.fn().mockResolvedValue([]);
BuildCheckoutPaymentTransaction = jest.fn().mockResolvedValue({
unsigned_transaction: 'base64tx',
Expand Down
10 changes: 6 additions & 4 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,13 @@ async function StartServer() {
console.log(`📊 Health check at http://localhost:${port}/api/health`);
console.log('');
const livemode = appConfig.livemode;
console.log(
livemode
const settlement =
appConfig.settlement_rail === 'simulated'
? '🧪 Simulated settlement (test mode)'
: livemode
? '💰 Solana: mainnet-beta (live USDC)'
: '🧪 Solana: devnet (test mode)'
);
: '🧪 Solana: devnet (test mode)';
console.log(settlement);
if (IsSingleTenantMode()) {
console.log('🔒 Single-tenant mode: Only one platform allowed');
} else {
Expand Down
28 changes: 26 additions & 2 deletions apps/api/src/modules/AppConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
import * as dotenv from 'dotenv';
import * as path from 'path';
import { randomBytes } from 'crypto';
import type { AppConfig, AppSecrets } from '@zoneless/shared-types';
import type {
AppConfig,
AppSecrets,
SettlementRail,
} from '@zoneless/shared-types';
import { DeriveKey } from './Encryption';

// Load environment variables first.
Expand Down Expand Up @@ -61,6 +65,24 @@ function NormalizeOrigin(url: string): string {
return url.replace(/\/$/, '');
}

/**
* Test mode defaults to simulated settlement. Live mode is always on-chain.
* SETTLEMENT_RAIL=onchain with LIVEMODE=false restores Solana Devnet.
*/
function ResolveSettlementRail(livemode: boolean): SettlementRail {
const rail = (process.env.SETTLEMENT_RAIL || '').trim().toLowerCase();
if (livemode) {
if (rail === 'simulated') {
throw new Error(
'SETTLEMENT_RAIL=simulated is not valid when LIVEMODE=true'
);
}
return 'onchain';
}
if (rail === 'onchain') return 'onchain';
return 'simulated';
}

/**
* Build the base config from environment variables.
* appSecret will be empty until InitializeAppConfig is called (if not in env).
Expand All @@ -74,6 +96,7 @@ function BuildConfigFromEnv(): AppConfig {
const paymentLinkUrl = NormalizeOrigin(
process.env.PAYMENT_LINK_URL || checkoutUrl
);
const livemode = process.env.LIVEMODE === 'true';

return {
mongodbUri:
Expand All @@ -83,7 +106,8 @@ function BuildConfigFromEnv(): AppConfig {
checkoutUrl,
paymentLinkUrl,
appSecret: process.env.APP_SECRET || '',
livemode: process.env.LIVEMODE === 'true',
livemode,
settlement_rail: ResolveSettlementRail(livemode),
};
}

Expand Down
14 changes: 9 additions & 5 deletions apps/api/src/modules/Balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { GetAppConfig } from './AppConfig';
import { GetPlatformAccountId } from './PlatformAccess';
import { AccountModule } from './Account';
import { ExternalWalletModule } from './ExternalWallet';
import { Solana } from './chains/Solana';
import { GetSettlement, IsSimulatedSettlement } from './chains/Settlement';

export class BalanceModule {
private readonly db: Database;
Expand Down Expand Up @@ -206,11 +206,15 @@ export class BalanceModule {

if (!wallet) return null;

const solana = new Solana();
const settlement = GetSettlement();
const [walletUsdc, walletSol, platformBalance, connectedOwed] =
await Promise.all([
solana.GetUSDCBalance(wallet.wallet_address),
solana.GetSOLBalance(wallet.wallet_address),
IsSimulatedSettlement()
? Promise.resolve(0)
: settlement.GetUSDCBalance(wallet.wallet_address),
IsSimulatedSettlement()
? Promise.resolve(0)
: settlement.GetSOLBalance(wallet.wallet_address),
this.GetBalance(platformAccountId),
this.GetConnectedAccountsOwed(platformAccountId),
]);
Expand All @@ -223,7 +227,7 @@ export class BalanceModule {

return {
object: 'balance_details',
wallet_usdc: walletUsdc,
wallet_usdc: IsSimulatedSettlement() ? available / 100 : walletUsdc,
wallet_sol: walletSol,
connected_accounts_owed: connectedOwed,
platform_available: available,
Expand Down
Loading
Loading