Skip to content

Commit 40c796c

Browse files
authored
feat: test mode uses simulated funds instead of devnet (#125)
1 parent 2ffb1cd commit 40c796c

39 files changed

Lines changed: 998 additions & 101 deletions

.agents/skills/zoneless-marketplace/SKILL.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,13 @@ the human explicitly asks for that additional work.
3030
Use plain language whenever the human must act. Briefly explain that USDC is a
3131
digital dollar, Solana is the network carrying it, SOL pays the small network
3232
fee, a wallet address is safe to share, and a wallet secret key authorizes
33-
spending and must remain private. Explain that devnet/test mode uses free,
34-
worthless test funds on a network separate from mainnet/live mode, and that a
35-
faucet is a website that supplies those test funds. Do not use terms such as
36-
gas, mint, token account, cluster, or airdrop without explaining them.
33+
spending and must remain private. Explain that test mode uses fake USDC so they
34+
can onboard sellers and run payouts without a wallet, a faucet, or the live
35+
Solana network. Live mode is when real USDC moves on Solana. Do not use terms
36+
such as gas, mint, token account, cluster, or airdrop without explaining them.
3737

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

4241
## Read documentation just in time
4342

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

198-
The SDK already builds and signs Solana transactions. Do not install
197+
The SDK already builds and signs Solana transactions. In simulated test mode
198+
it passes dummy `sim_tx` payloads through without signing. Do not install
199199
`@solana/web3.js`, `@solana/spl-token`, `bs58`, or add mint, cluster, RPC, token
200200
account, or platform-account configuration to a Node application. Those are
201201
Zoneless implementation details. The marketplace app needs only the relevant
@@ -351,14 +351,13 @@ Report:
351351
- required environment-variable names, never values;
352352
- tests run and any unverified behavior;
353353
- the test-mode onboarding path;
354-
- that before an end-to-end test payout, the human must fund the test
355-
platform's public wallet address (the `wallet_public_key` returned by setup or
356-
the address shown under **Balance**) with devnet SOL from
357-
`https://faucet.solana.com/` and test USDC from
358-
`https://faucet.circle.com/`, selecting **USDC** and **Solana Devnet** at the
359-
Circle faucet; link
360-
`https://zoneless.com/docs/local-development.md` for the complete steps and
361-
warn never to send real SOL or USDC to devnet;
354+
- that a test payout still uses the live two-step flow: create stays
355+
pending, then `processAll` / build and broadcast marks it paid with
356+
simulated USDC. No wallet, faucet, or Devnet is required — broadcast
357+
the unsigned dummy transaction. Use **Add test USDC**
358+
on the dashboard Balance page (or
359+
`POST /v1/test_helpers/treasury/topups`) if the platform ledger needs a
360+
balance first;
362361
- where the human must configure the API key and webhook secret;
363362
- the exact live-mode promotion sequence, including the bound live profile
364363
name, `https://api.zoneless.com`, deployment secret changes, live webhook

.agents/skills/zoneless-payments/SKILL.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ explicitly asks for that additional work.
3030
Use plain language whenever the human must act. Briefly explain that USDC is a
3131
digital dollar and Solana is the network carrying it, that a wallet address is
3232
safe to share, and that a wallet secret key authorizes spending and must remain
33-
private. Explain that test mode uses free, worthless test funds on a network
34-
separate from live mode, and that a faucet is a website that supplies those
35-
test funds. Do not use terms such as gas, mint, token account, cluster, or
36-
airdrop without explaining them.
33+
private. Explain that test mode uses fake USDC so they can try checkout
34+
without a wallet, a faucet, or the live Solana network. Live mode is when
35+
real USDC moves on Solana. Do not use terms such as gas, mint, token account,
36+
cluster, or airdrop without explaining them.
3737

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

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

372372
## Human handoff
373373

@@ -381,10 +381,10 @@ Report:
381381
`ZONELESS_API_URL`, and `ZONELESS_WEBHOOK_SECRET`), never values;
382382
- checks run, distinguishing static checks, unit tests, hosted-checkout
383383
verification, and completed end-to-end payments;
384-
- that to test a subscription end to end, the human needs a Solana wallet
385-
holding test USDC, available free from `https://faucet.circle.com/` by
386-
selecting **USDC** and **Solana Devnet**, and that they must never send real
387-
funds to a test network;
384+
- that to test a subscription end to end, the human opens hosted checkout in
385+
test mode and approves the simulated wallet (no Phantom, faucet, or Devnet
386+
switch). They can also complete a session with
387+
`POST /v1/test_helpers/checkout/sessions/:id/complete`;
388388
- that the customer approves the plan once at checkout and every later cycle is
389389
collected automatically, so no further signing is required;
390390
- where the human must configure the live API key, live API URL, and live

.env.example

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,18 @@ SINGLE_TENANT=
4545
# /v1/operator API. Implies multi-tenant mode (SINGLE_TENANT is ignored).
4646
OPERATOR_API_KEY=
4747

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

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

55+
# How test mode settles money: simulated (default, fake funds) or onchain
56+
# (Solana Devnet — internal escape hatch). Live mode is always on-chain.
57+
# SETTLEMENT_RAIL=simulated
58+
# SETTLEMENT_RAIL=onchain
59+
5560
# Base58 secret key that owns on-chain subscription plans and signs pulls.
5661
# Required to create recurring prices. Existing plans must keep the same owner.
5762
# SUBSCRIPTION_OPERATOR_KEY=

.env.production.example

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,18 @@ SINGLE_TENANT=true
3838
# /v1/operator API. Implies multi-tenant mode (SINGLE_TENANT is ignored).
3939
OPERATOR_API_KEY=
4040

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

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

49+
# Test mode settlement: simulated (default) or onchain (Solana Devnet).
50+
# Invalid when LIVEMODE=true.
51+
# SETTLEMENT_RAIL=onchain
52+
4853
# Base58 secret key that owns on-chain subscription plans and signs pulls.
4954
# Required to create recurring prices. Must match the owner used when plans
5055
# were first created.

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ npx nx lint api --fix
3131
npx nx lint web --fix
3232
```
3333

34+
Test mode uses simulated funds by default (`SETTLEMENT_RAIL=simulated`). To
35+
exercise Solana Devnet instead, set `LIVEMODE=false` and `SETTLEMENT_RAIL=onchain`.
36+
3437
## How to Contribute
3538

3639
### Reporting Bugs

apps/api/src/__tests__/Payout.spec.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ jest.mock('../modules/AppConfig', () => ({
2222
appSecret: 'test-secret',
2323
})),
2424
}));
25-
jest.mock('../modules/chains/Solana', () => ({
26-
Solana: jest.fn().mockImplementation(() => ({
25+
jest.mock('../modules/chains/Settlement', () => ({
26+
GetSettlement: () => ({
2727
CheckWalletExists: jest.fn().mockResolvedValue(true),
2828
GetUSDCBalance: jest.fn().mockResolvedValue(100),
2929
GetSOLBalance: jest.fn().mockResolvedValue(1),
@@ -39,7 +39,8 @@ jest.mock('../modules/chains/Solana', () => ({
3939
signature: 'sig123',
4040
viewer_url: 'https://solscan.io/tx/sig123',
4141
}),
42-
})),
42+
}),
43+
IsSimulatedSettlement: jest.fn(() => false),
4344
}));
4445

4546
describe('PayoutModule', () => {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
SimulatedSettlement,
3+
SimulatedSignature,
4+
} from '../modules/chains/Settlement';
5+
6+
describe('SimulatedSettlement', () => {
7+
const settlement = new SimulatedSettlement();
8+
9+
it('accepts a plausible wallet address', async () => {
10+
await expect(
11+
settlement.CheckWalletExists(
12+
'D8VMZCmmTUUfhejNhNQKAmqvZCKfUq1qU6RqQKxQwXyX'
13+
)
14+
).resolves.toBe(true);
15+
await expect(settlement.CheckWalletExists('short')).resolves.toBe(false);
16+
});
17+
18+
it('verifies a simulated checkout signature', async () => {
19+
const signature = SimulatedSignature('cs_z_1', 'Payer111');
20+
const result = await settlement.VerifyCheckoutPayment(signature, {
21+
merchantWalletAddress: 'Merchant111',
22+
amountInCents: 1500,
23+
checkoutSessionId: 'cs_z_1',
24+
});
25+
expect(result).toEqual({
26+
verified: true,
27+
amount_cents: 1500,
28+
payer_address: 'Payer111',
29+
});
30+
});
31+
32+
it('rejects a non-simulated checkout signature', async () => {
33+
const result = await settlement.VerifyCheckoutPayment('real_sig', {
34+
merchantWalletAddress: 'Merchant111',
35+
amountInCents: 1500,
36+
checkoutSessionId: 'cs_z_1',
37+
});
38+
expect(result.verified).toBe(false);
39+
});
40+
41+
it('skips subscription authority init so checkout is one approval', async () => {
42+
await expect(
43+
settlement.BuildInitSubscriptionAuthorityTransaction('Payer111')
44+
).resolves.toBeNull();
45+
});
46+
47+
it('collects a subscription without hitting a chain', async () => {
48+
await expect(
49+
settlement.CollectSubscriptionPayment({
50+
subscriberWallet: 'Payer111',
51+
planPda: 'sim_plan_price_z_1',
52+
subscriptionPda: 'sim_sub_1',
53+
amountCents: 2000,
54+
})
55+
).resolves.toEqual({
56+
signature: 'sim_collect:Payer111',
57+
alreadyCollected: false,
58+
});
59+
});
60+
61+
it('builds and broadcasts a dummy payout without a chain', async () => {
62+
await expect(
63+
settlement.BuildBatchPayoutTransaction('Sender111', [
64+
{ destinationAddress: 'Dest111', amountInCents: 500 },
65+
])
66+
).resolves.toMatchObject({
67+
unsigned_transaction: 'sim_tx',
68+
last_valid_block_height: 1,
69+
recipients_count: 1,
70+
});
71+
await expect(
72+
settlement.BroadcastSignedTransaction('sim_tx')
73+
).resolves.toEqual({
74+
signature: 'sim_sig_payout',
75+
status: 'paid',
76+
viewer_url: '',
77+
});
78+
});
79+
});

apps/api/src/__tests__/mocks/Solana.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class Solana {
3030
GetSOLBalance = jest.fn().mockResolvedValue(1);
3131
GetUSDCMintAddress = jest.fn().mockReturnValue('UsdcMint_test');
3232
GetPlanOwnerPublicKey = jest.fn().mockReturnValue('PlanOwner111');
33+
GetIncomingDeposits = jest.fn().mockResolvedValue([]);
3334
GetIncomingUSDCDeposits = jest.fn().mockResolvedValue([]);
3435
BuildCheckoutPaymentTransaction = jest.fn().mockResolvedValue({
3536
unsigned_transaction: 'base64tx',

apps/api/src/main.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,11 +225,13 @@ async function StartServer() {
225225
console.log(`📊 Health check at http://localhost:${port}/api/health`);
226226
console.log('');
227227
const livemode = appConfig.livemode;
228-
console.log(
229-
livemode
228+
const settlement =
229+
appConfig.settlement_rail === 'simulated'
230+
? '🧪 Simulated settlement (test mode)'
231+
: livemode
230232
? '💰 Solana: mainnet-beta (live USDC)'
231-
: '🧪 Solana: devnet (test mode)'
232-
);
233+
: '🧪 Solana: devnet (test mode)';
234+
console.log(settlement);
233235
if (IsSingleTenantMode()) {
234236
console.log('🔒 Single-tenant mode: Only one platform allowed');
235237
} else {

apps/api/src/modules/AppConfig.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
import * as dotenv from 'dotenv';
2323
import * as path from 'path';
2424
import { randomBytes } from 'crypto';
25-
import type { AppConfig, AppSecrets } from '@zoneless/shared-types';
25+
import type {
26+
AppConfig,
27+
AppSecrets,
28+
SettlementRail,
29+
} from '@zoneless/shared-types';
2630
import { DeriveKey } from './Encryption';
2731

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

68+
/**
69+
* Test mode defaults to simulated settlement. Live mode is always on-chain.
70+
* SETTLEMENT_RAIL=onchain with LIVEMODE=false restores Solana Devnet.
71+
*/
72+
function ResolveSettlementRail(livemode: boolean): SettlementRail {
73+
const rail = (process.env.SETTLEMENT_RAIL || '').trim().toLowerCase();
74+
if (livemode) {
75+
if (rail === 'simulated') {
76+
throw new Error(
77+
'SETTLEMENT_RAIL=simulated is not valid when LIVEMODE=true'
78+
);
79+
}
80+
return 'onchain';
81+
}
82+
if (rail === 'onchain') return 'onchain';
83+
return 'simulated';
84+
}
85+
6486
/**
6587
* Build the base config from environment variables.
6688
* appSecret will be empty until InitializeAppConfig is called (if not in env).
@@ -74,6 +96,7 @@ function BuildConfigFromEnv(): AppConfig {
7496
const paymentLinkUrl = NormalizeOrigin(
7597
process.env.PAYMENT_LINK_URL || checkoutUrl
7698
);
99+
const livemode = process.env.LIVEMODE === 'true';
77100

78101
return {
79102
mongodbUri:
@@ -83,7 +106,8 @@ function BuildConfigFromEnv(): AppConfig {
83106
checkoutUrl,
84107
paymentLinkUrl,
85108
appSecret: process.env.APP_SECRET || '',
86-
livemode: process.env.LIVEMODE === 'true',
109+
livemode,
110+
settlement_rail: ResolveSettlementRail(livemode),
87111
};
88112
}
89113

0 commit comments

Comments
 (0)