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
13 changes: 0 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,6 @@ jobs:
path: target/deploy/subscriptions.so
retention-days: 1

- name: Upload program keypair
uses: actions/upload-artifact@v4
with:
name: program-keypair
path: target/deploy/subscriptions-keypair.json
retention-days: 1

- name: Upload client dist
uses: actions/upload-artifact@v4
with:
Expand Down Expand Up @@ -116,12 +109,6 @@ jobs:
name: program-so
path: target/deploy/

- name: Download program keypair
uses: actions/download-artifact@v4
with:
name: program-keypair
path: target/deploy/

- name: Download generated clients
uses: actions/download-artifact@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ config.json
# Environment secrets
.env

keys/

# Playwright output
apps/web/playwright-report/
apps/web/test-results/
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ clients/typescript/src/generated/ (auto-generated; wrapped by hand-written SDK

### Program ID

`EPEUTog1kptYkthDJF6MuB1aM4aDAwHYwoF32Rzv5rqg`
`De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44`

## Audit Status

Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/instructions/Subscribe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export function Subscribe() {
const [merchant, setMerchant] = useState('');
const [planId, setPlanId] = useState('0');
const [tokenMint, setTokenMint] = useState('');
const [expectedAmount, setExpectedAmount] = useState('0');
const [expectedPeriodHours, setExpectedPeriodHours] = useState('0');
const [expectedCreatedAt, setExpectedCreatedAt] = useState('0');

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
Expand All @@ -27,6 +30,9 @@ export function Subscribe() {
const { instructions, subscriptionPda } = await buildSubscribe({
subscriber: signer, merchant: merchant.trim() as Address,
planId: BigInt(planId), tokenMint: tokenMint.trim() as Address,
expectedAmount: BigInt(expectedAmount),
expectedPeriodHours: BigInt(expectedPeriodHours),
expectedCreatedAt: BigInt(expectedCreatedAt),
programAddress: getProgramAddress(),
});

Expand All @@ -46,6 +52,12 @@ export function Subscribe() {
<FormField label="Token Mint" value={tokenMint} onChange={setTokenMint}
autoFillValue={defaultMint} onAutoFill={setTokenMint}
placeholder="Mint address" required />
<FormField label="Expected Amount" value={expectedAmount} onChange={setExpectedAmount} type="number"
hint="Live plan terms.amount; binds subscriber consent" required />
<FormField label="Expected Period Hours" value={expectedPeriodHours} onChange={setExpectedPeriodHours} type="number"
hint="Live plan terms.periodHours" required />
<FormField label="Expected Created At" value={expectedCreatedAt} onChange={setExpectedCreatedAt} type="number"
hint="Live plan terms.createdAt (unix ts)" required />
<SendButton sending={sending} />
<TxResultDisplay signature={signature} error={error} />
</form>
Expand Down
32 changes: 29 additions & 3 deletions clients/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
} from './accounts/delegations.js';
import { fetchPlansForOwner } from './accounts/plans.js';
import type { PlanStatus } from './generated/index.js';
import { fetchMaybeSubscriptionAuthority } from './generated/index.js';
import {
fetchMaybeSubscriptionAuthority,
fetchPlan,
} from './generated/index.js';
import {
buildCloseSubscriptionAuthority,
buildCreateFixedDelegation,
Expand All @@ -29,7 +32,7 @@ import {
buildTransferRecurring,
buildTransferSubscription,
} from './instructions/transfer.js';
import { getSubscriptionAuthorityPDA } from './pdas.js';
import { getPlanPDA, getSubscriptionAuthorityPDA } from './pdas.js';
import type { SolanaClient, TransactionResult } from './types/common.js';
import type { Delegation } from './types/delegation.js';
import type { PlanWithAddress } from './types/plan.js';
Expand Down Expand Up @@ -319,9 +322,32 @@ export class SubscriptionsClient {
merchant: Address;
planId: number | bigint;
tokenMint: Address;
/** Plan terms the subscriber consents to. If omitted, the live plan is
* fetched and its current terms are used; the program still rejects on
* mismatch at submit time. */
expectedAmount?: number | bigint;
expectedPeriodHours?: number | bigint;
expectedCreatedAt?: number | bigint;
payer?: TransactionSigner;
}): Promise<TransactionResult & { subscriptionPda: Address }> {
const { instructions, subscriptionPda } = await buildSubscribe(params);
let { expectedAmount, expectedPeriodHours, expectedCreatedAt } = params;
if (
expectedAmount === undefined ||
expectedPeriodHours === undefined ||
expectedCreatedAt === undefined
) {
const [planPda] = await getPlanPDA(params.merchant, params.planId);
const plan = await fetchPlan(this.client.rpc, planPda);
expectedAmount ??= plan.data.data.terms.amount;
expectedPeriodHours ??= plan.data.data.terms.periodHours;
expectedCreatedAt ??= plan.data.data.terms.createdAt;
}
const { instructions, subscriptionPda } = await buildSubscribe({
...params,
expectedAmount,
expectedPeriodHours,
expectedCreatedAt,
});
const signature = await this.buildAndSendTransaction(
instructions,
params.payer ?? params.subscriber,
Expand Down
27 changes: 24 additions & 3 deletions clients/typescript/src/instructions/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,25 @@ export async function buildSubscribe(params: {
merchant: Address;
planId: number | bigint;
tokenMint: Address;
/** Plan terms the subscriber is consenting to. Caller must fetch from the
* live plan; the program rejects if the on-chain plan disagrees at submit. */
expectedAmount: number | bigint;
expectedPeriodHours: number | bigint;
expectedCreatedAt: number | bigint;
payer?: TransactionSigner;
programAddress?: Address;
}): Promise<{ instructions: Instruction[]; subscriptionPda: Address }> {
const { subscriber, merchant, planId, tokenMint, payer, programAddress } =
params;
const {
subscriber,
merchant,
planId,
tokenMint,
expectedAmount,
expectedPeriodHours,
expectedCreatedAt,
payer,
programAddress,
} = params;
const config = programAddress ? { programAddress } : undefined;

const [planPda, planBump] = await getPlanPDA(
Expand All @@ -61,7 +75,14 @@ export async function buildSubscribe(params: {
planPda,
subscriptionPda,
subscriptionAuthorityPda,
subscribeData: { planId, planBump },
subscribeData: {
planId,
planBump,
expectedMint: tokenMint,
expectedAmount,
expectedPeriodHours,
expectedCreatedAt,
},
},
config,
);
Expand Down
63 changes: 48 additions & 15 deletions clients/typescript/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export const SURFPOOL_RPC_URL = `http://127.0.0.1:${SURFPOOL_PORT}`;
export const DEFAULT_TEST_BALANCE = 1_000_000n;
export const ONE_HOUR_IN_SECONDS = 3600;
export const ONE_DAY_IN_SECONDS = 86400;
const SYSVAR_CLOCK_ADDRESS =
'SysvarC1ock11111111111111111111111111111111' as Address;
const SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET = 32;

type SolanaClient = ReturnType<typeof createSolanaClient>;
export type SmartWalletName = 'swig' | 'squads';
Expand Down Expand Up @@ -147,6 +150,7 @@ export class IntegrationTest {
*/
static async create(): Promise<IntegrationTest> {
await isSurfnetRunning(); // Just verify surfpool is running

const solanaClient = createSolanaClient({ urlOrMoniker: 'localnet' });
const client = new SubscriptionsClient(solanaClient);

Expand Down Expand Up @@ -216,10 +220,17 @@ export class IntegrationTest {
}

async getValidatorTime(): Promise<bigint> {
const clockTime = await getClockSysvarTime(this.rpc);
if (clockTime != null) return clockTime;

const slot = await this.rpc.getSlot().send();
const blockTime = await this.rpc.getBlockTime(slot).send();
if (blockTime == null) throw new Error('blockTime is null');
return BigInt(blockTime);
const wall = BigInt(Math.floor(Date.now() / 1000));
if (blockTime != null) {
const ts = BigInt(blockTime);
if (ts + 60n >= wall) return ts;
}
return wall;
}

async minPlanEndTs(periodHours: bigint): Promise<bigint> {
Expand All @@ -229,19 +240,7 @@ export class IntegrationTest {
}

async timeTravel(targetTimestampSec: number): Promise<void> {
const res = await fetch(SURFPOOL_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'surfnet_timeTravel',
params: [{ absoluteTimestamp: targetTimestampSec * 1000 }],
}),
});
if (!res.ok) throw new Error(`surfnet_timeTravel failed: ${res.status}`);
const data = (await res.json()) as { error?: { message: string } };
if (data.error) throw new Error(data.error.message);
await setSurfpoolClock(targetTimestampSec);
}

private smartWalletsInitialized = false;
Expand Down Expand Up @@ -306,6 +305,40 @@ export class IntegrationTest {
// Private Helper Functions
// ============================================================================

async function setSurfpoolClock(targetTimestampSec: number): Promise<void> {
const res = await fetch(SURFPOOL_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'surfnet_timeTravel',
params: [{ absoluteTimestamp: targetTimestampSec * 1000 }],
}),
});
if (!res.ok) throw new Error(`surfnet_timeTravel failed: ${res.status}`);
const data = (await res.json()) as { error?: { message: string } };
if (data.error) throw new Error(data.error.message);
}

async function getClockSysvarTime(
rpc: SolanaClient['rpc'],
): Promise<bigint | null> {
const account = await rpc
.getAccountInfo(SYSVAR_CLOCK_ADDRESS, { encoding: 'base64' })
.send();
const encodedData = account.value?.data;
if (!Array.isArray(encodedData) || typeof encodedData[0] !== 'string') {
return null;
}

const clockData = Buffer.from(encodedData[0], 'base64');
if (clockData.length < SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET + 8) {
return null;
}
return clockData.readBigInt64LE(SYSVAR_CLOCK_UNIX_TIMESTAMP_OFFSET);
}

/**
* Checks that Surfpool is running and returns the RPC URL.
*
Expand Down
66 changes: 27 additions & 39 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ set shell := ["bash", "-uc"]
program_dir := "programs/subscriptions"
ts_client_dir := "clients/typescript"
webapp_dir := "webapp"
deploy_key := "keys/subscriptions-keypair.json"
target_deploy_key := "target/deploy/subscriptions-keypair.json"
idl_file := program_dir / "idl/subscriptions.json"

# List available recipes
Expand Down Expand Up @@ -43,33 +41,7 @@ setup-hooks:

# Print program ID from keypair
program-id:
@solana-keygen pubkey "{{deploy_key}}"

# Copy deployment keypair to build directory
prepare-deploy-keys:
#!/usr/bin/env bash
set -euo pipefail

mkdir -p "target/deploy"

if [[ ! -f "{{deploy_key}}" ]]; then
echo "Error: {{deploy_key}} not found."
echo "This keypair defines the program identity. Do not generate a random one."
echo "Restore it from git history: git show <commit>^:keys/subscriptions-keypair.json > {{deploy_key}}"
exit 1
fi

PROG_ID=$(solana-keygen pubkey "{{deploy_key}}")
DECLARED_ID=$(sed -n 's/.*declare_id!("\([^"]*\)").*/\1/p' "{{program_dir}}/src/lib.rs")

if [[ "$DECLARED_ID" != "$PROG_ID" ]]; then
echo "Error: declare_id! ($DECLARED_ID) does not match keypair ($PROG_ID)"
echo "Update declare_id! in {{program_dir}}/src/lib.rs or restore the correct keypair."
exit 1
fi

cp "{{deploy_key}}" "{{target_deploy_key}}"
echo "✓ Deploy key ready (program: $PROG_ID)"
@sed -n 's/.*declare_id!("\([^"]*\)").*/\1/p' "{{program_dir}}/src/lib.rs"

# ============================================
# Build recipes
Expand All @@ -85,10 +57,10 @@ needs-rebuild target source:
fi

# Build everything (program + clients)
build: prepare-deploy-keys build-program build-client
build: build-program build-client

# Compile Solana program to .so
build-program: prepare-deploy-keys
build-program:
cd {{program_dir}} && cargo build-sbf
@echo "✓ Program built"

Expand Down Expand Up @@ -163,11 +135,16 @@ ensure-surfpool:
exit 0
fi

PROG_ID=$(solana-keygen pubkey "{{deploy_key}}")
PROG_ID=$(sed -n 's/.*declare_id!("\([^"]*\)").*/\1/p' "{{program_dir}}/src/lib.rs")
if [[ -z "$PROG_ID" ]]; then
echo "Error: could not parse declare_id! from {{program_dir}}/src/lib.rs"
exit 1
fi

echo "Starting surfpool validator..."
mkdir -p .surfpool
nohup surfpool start --ci --no-tui --block-production-mode transaction \
--runbook surfnet-setup \
> /tmp/surfpool.log 2>&1 &
echo $! > .surfpool/pid.txt

Expand Down Expand Up @@ -307,16 +284,24 @@ check: fmt-check lint-check
check-program-metadata:
@command -v program-metadata >/dev/null 2>&1 || { echo "Error: program-metadata not installed. See https://github.com/solana-program/program-metadata"; exit 1; }

# Deploy IDL to devnet
# Deploy IDL to devnet (requires PROGRAM_UPGRADE_AUTHORITY_KEYPAIR env var)
deploy-idl-devnet: check-program-metadata
program-metadata write idl $(solana-keygen pubkey "{{deploy_key}}") {{idl_file}} \
--keypair {{deploy_key}} \
#!/usr/bin/env bash
set -euo pipefail
KP="${PROGRAM_UPGRADE_AUTHORITY_KEYPAIR:?Set PROGRAM_UPGRADE_AUTHORITY_KEYPAIR (e.g. via doppler run -- just deploy-idl-devnet)}"
PROG_ID=$(sed -n 's/.*declare_id!("\([^"]*\)").*/\1/p' "{{program_dir}}/src/lib.rs")
program-metadata write idl "$PROG_ID" {{idl_file}} \
--keypair "$KP" \
--rpc https://api.devnet.solana.com

# Deploy IDL to mainnet
# Deploy IDL to mainnet (requires PROGRAM_UPGRADE_AUTHORITY_KEYPAIR env var)
deploy-idl-mainnet: check-program-metadata
program-metadata write idl $(solana-keygen pubkey "{{deploy_key}}") {{idl_file}} \
--keypair {{deploy_key}} \
#!/usr/bin/env bash
set -euo pipefail
KP="${PROGRAM_UPGRADE_AUTHORITY_KEYPAIR:?Set PROGRAM_UPGRADE_AUTHORITY_KEYPAIR (e.g. via doppler run -- just deploy-idl-mainnet)}"
PROG_ID=$(sed -n 's/.*declare_id!("\([^"]*\)").*/\1/p' "{{program_dir}}/src/lib.rs")
program-metadata write idl "$PROG_ID" {{idl_file}} \
--keypair "$KP" \
--rpc https://api.mainnet-beta.solana.com

# ============================================
Expand All @@ -330,9 +315,12 @@ check-solana-verify:
# Verify mainnet deployment against repo (remote build via OtterSec).
# Note: Remote verification (--remote) only works on mainnet.
verify-mainnet: check-solana-verify
#!/usr/bin/env bash
set -euo pipefail
PROG_ID=$(sed -n 's/.*declare_id!("\([^"]*\)").*/\1/p' "{{program_dir}}/src/lib.rs")
solana-verify verify-from-repo \
https://github.com/solana-program/multi-delegator \
--program-id $(solana-keygen pubkey "{{deploy_key}}") \
--program-id "$PROG_ID" \
--library-name subscriptions \
--mount-path programs/subscriptions \
--remote \
Expand Down
1 change: 0 additions & 1 deletion keys/subscriptions-keypair.json

This file was deleted.

Loading
Loading