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
79 changes: 77 additions & 2 deletions e2e/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions e2e/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ async function fundAccount(
}
}

async function registerEntity(
providerUrl: string,
pubkey: string,
name: string,
): Promise<void> {
const res = await fetch(`${providerUrl}/api/v1/entities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pubkey, name, jurisdictions: [] }),
});
// Idempotent: 409 means already APPROVED, treat as success.
if (!res.ok && res.status !== 409) {
throw new Error(
`Entity registration failed for ${pubkey}: ${res.status} ${await res
.text()}`,
);
}
}

async function main() {
const startTime = Date.now();

Expand Down Expand Up @@ -66,6 +85,15 @@ async function main() {
);
console.log(` Bob authenticated`);

// Step 4b: Register Alice + Bob as APPROVED entities (KYC/KYB). Bundle
// submission now gates on the submitter's entity being APPROVED.
console.log("\n[4b/8] Registering Alice + Bob as APPROVED entities...");
await withE2ESpan("e2e.register_entities", async () => {
await registerEntity(config.providerUrl, alice.publicKey(), "Alice");
await registerEntity(config.providerUrl, bob.publicKey(), "Bob");
});
console.log(" Entities registered");

// Step 5: Alice deposits into channel
console.log(`\n[5/8] Alice depositing ${DEPOSIT_AMOUNT} XLM into channel...`);
await withE2ESpan(
Expand Down
4 changes: 4 additions & 0 deletions e2e/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ E2E_CHANNEL_ASSET_CONTRACT_ID=$TOKEN_ID
E2E_PROVIDER_PK=$PROVIDER_PK
E2E_PROVIDER_SK=$PROVIDER_SK
E2E_TREASURY_PK=$TREASURY_PK
# Bundles are URL-scoped to /providers/:ppPublicKey/bundles. lib/client/config.ts
# requires E2E_PP_PUBLIC_KEY for every submission; in the single-PP e2e harness
# it is the same as E2E_PROVIDER_PK.
E2E_PP_PUBLIC_KEY=$PROVIDER_PK
EOF

# --- Write seed.json for provider DB seeding ---
Expand Down
28 changes: 9 additions & 19 deletions lib/client/bundle.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,19 @@
import type { Config } from "./config.ts";
import { withE2ESpan } from "./tracer.ts";

export interface SubmitBundleOptions {
jurisdictionFrom?: string;
jurisdictionTo?: string;
}

export function submitBundle(
jwt: string,
operationsMLXDR: string[],
config: Config,
options: SubmitBundleOptions = {},
): Promise<string> {
return withE2ESpan("bundle.submit", async () => {
const maxRetries = 10;
const retryDelayMs = 5_000;
const url =
`${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/bundles`;

for (let attempt = 0; attempt < maxRetries; attempt++) {
const res = await fetch(`${config.providerUrl}/api/v1/bundle`, {
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand All @@ -26,17 +22,11 @@ export function submitBundle(
body: JSON.stringify({
operationsMLXDR,
channelContractId: config.channelContractId,
...(options.jurisdictionFrom !== undefined
? { jurisdictionFrom: options.jurisdictionFrom }
: {}),
...(options.jurisdictionTo !== undefined
? { jurisdictionTo: options.jurisdictionTo }
: {}),
}),
});

if (res.status === 429) {
await res.text(); // drain body
await res.text();
await new Promise((r) => setTimeout(r, retryDelayMs));
continue;
}
Expand Down Expand Up @@ -66,16 +56,16 @@ export function waitForBundle(
): Promise<void> {
return withE2ESpan("bundle.wait", async () => {
const start = Date.now();
const url =
`${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/bundles/${bundleId}`;

while (Date.now() - start < timeoutMs) {
await new Promise((r) => setTimeout(r, pollIntervalMs));

const res = await fetch(
`${config.providerUrl}/api/v1/bundle/${bundleId}`,
{ headers: { "Authorization": `Bearer ${jwt}` } },
);
const res = await fetch(url, {
headers: { "Authorization": `Bearer ${jwt}` },
});

// Retry on rate limit
if (res.status === 429) {
await new Promise((r) => setTimeout(r, pollIntervalMs));
continue;
Expand Down
12 changes: 12 additions & 0 deletions lib/client/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface Config {
horizonUrl: string;
friendbotUrl: string;
providerUrl: string;
/** PP that owns this bundle's processing — encoded into the URL. */
ppPublicKey: string;
channelContractId: ContractId;
channelAuthId: ContractId;
channelAssetContractId: ContractId;
Expand Down Expand Up @@ -74,12 +76,22 @@ export function loadConfig(): Config {
const providerSecretKey = Deno.env.get("E2E_PROVIDER_SK") ??
env["E2E_PROVIDER_SK"];

const ppPublicKey = Deno.env.get("E2E_PP_PUBLIC_KEY") ??
env["E2E_PP_PUBLIC_KEY"] ?? "";
if (!ppPublicKey) {
throw new Error(
"Missing E2E_PP_PUBLIC_KEY — bundle submissions are URL-scoped per PP. " +
"Set E2E_PP_PUBLIC_KEY before loading the config.",
);
}

return {
networkPassphrase,
rpcUrl,
horizonUrl,
friendbotUrl,
providerUrl,
ppPublicKey,
channelContractId,
channelAuthId,
channelAssetContractId,
Expand Down
2 changes: 1 addition & 1 deletion lib/client/deposit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export async function deposit(

// 5. Submit bundle
const operationsMLXDR = [depositOp.toMLXDR(), createOp.toMLXDR()];
const bundleId = await submitBundle(jwt, operationsMLXDR, config, options);
const bundleId = await submitBundle(jwt, operationsMLXDR, config);
console.log(` Bundle submitted: ${bundleId}`);

await waitForBundle(jwt, bundleId, config);
Expand Down
82 changes: 82 additions & 0 deletions lib/client/fail-inject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import {
MoonlightOperation,
type MoonlightTracer,
UTXOStatus,
} from "@moonlight/moonlight-sdk";
import type { Config } from "./config.ts";
import { getLatestLedger, setupAccount } from "./account.ts";
import { submitBundle } from "./bundle.ts";
import { deposit } from "./deposit.ts";

/**
* Submits a bundle that the server admits (entity APPROVED, channel
* membership OK, signatures valid, UTXO exists on chain) but the executor's
* on-chain simulation rejects. Result: bundle settles as FAILED.
*
* Trick: we deposit a small known amount to mint a fresh UNSPENT UTXO,
* then build a SPEND of that UTXO with a CREATE condition for 100× the
* amount. The server's pre-validation passes (UTXO exists, signatures
* verify) but the on-chain channel contract enforces sum(SPEND) ==
* sum(CREATE) and rejects.
*/
export async function injectFailingBundle(
senderSecret: string,
jwt: string,
config: Config,
tracer?: MoonlightTracer,
): Promise<string> {
// 0.5 + 0.05 deposit fee = 0.55 (clean float). Using 0.1 would produce
// 0.15000000000000002 inside Colibri's fromDecimals and trip its
// fractional-digits guard before the bundle is ever submitted.
const FAIL_DEPOSIT = 0.5;

// 1. Deposit a small amount so the executor sees an on-chain UTXO. The
// deposit completes normally — we'll overspend its product UTXO next.
await deposit(senderSecret, FAIL_DEPOSIT, jwt, config, tracer);

// 2. Re-derive the account so the new UNSPENT UTXO is loaded.
const { accountHandler } = await setupAccount(
senderSecret,
config,
2,
tracer,
);

const unspent = accountHandler.getUTXOsByState(UTXOStatus.UNSPENT);
const source = unspent.find((u) => u.balance > 0n);
if (!source) {
throw new Error(
"No UNSPENT UTXO found after deposit — can't forge overspend",
);
}

// 3. Build a CREATE for source.balance × 2. The on-chain channel contract
// enforces sum(SPEND) == sum(CREATE), so the executor's fee aggregation
// rejects (sum-of-spends − sum-of-creates < 0 → MoonlightOperation.create
// asserts amount > 0). Using 2× keeps the amount a clean fixed-point
// multiple (avoids the SDK's float-precision-fractional-digits guard
// that trips on, e.g., balance + 1n stroop).
const free = accountHandler.getUTXOsByState(UTXOStatus.FREE);
if (free.length < 1) {
throw new Error("No FREE UTXO available to sink the overspend CREATE");
}
const sink = free[0];
const createOp = MoonlightOperation.create(
sink.publicKey,
source.balance * 2n,
);

const ledgerSequence = await getLatestLedger(config.rpcUrl);
const expiration = ledgerSequence + 1000;

let spendOp = MoonlightOperation.spend(source.publicKey);
spendOp = spendOp.addCondition(createOp.toCondition());
const signedSpend = await spendOp.signWithUTXO(
source,
config.channelContractId,
expiration,
);

const operationsMLXDR = [createOp.toMLXDR(), signedSpend.toMLXDR()];
return await submitBundle(jwt, operationsMLXDR, config);
}
12 changes: 10 additions & 2 deletions lib/client/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const SEND_FEE = 0.1; // LOW entropy fee
export interface SendOptions {
jurisdictionFrom?: string;
jurisdictionTo?: string;
// When false, return the bundle id immediately after submission without
// awaiting completion. Used by send-loop's expire mode so it can force-
// expire the bundle before it settles.
waitForCompletion?: boolean;
}

export async function send(
Expand All @@ -22,7 +26,7 @@ export async function send(
config: Config,
tracer?: MoonlightTracer,
options: SendOptions = {},
): Promise<void> {
): Promise<string> {
const feeBigInt = fromDecimals(SEND_FEE, 7);
const amountBigInt = fromDecimals(amount, 7);
const totalToSpend = amountBigInt + feeBigInt;
Expand Down Expand Up @@ -92,9 +96,13 @@ export async function send(
...createOps.map((op) => op.toMLXDR()),
...spendOps.map((op) => op.toMLXDR()),
];
const bundleId = await submitBundle(jwt, operationsMLXDR, config, options);
const bundleId = await submitBundle(jwt, operationsMLXDR, config);
console.log(` Bundle submitted: ${bundleId}`);

if (options.waitForCompletion === false) {
return bundleId;
}
await waitForBundle(jwt, bundleId, config);
console.log(` Bundle completed`);
return bundleId;
}
Loading
Loading