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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@moonlight-protocol/pay-platform",
"version": "0.5.25",
"version": "0.5.26",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
50 changes: 33 additions & 17 deletions src/http/v1/pay/instant-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ import {
STELLAR_RPC_URL,
} from "@/config/env.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";
import { SpanStatusCode, withSpan } from "@/core/tracing.ts";
import { PlatformError } from "@/error/index.ts";
import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts";
import {
bundleSettlementFailed,
providerBundleRejected,
} from "@/http/v1/pay/pay.errors.ts";

const councilRepo = new CouncilRepository(drizzleClient);
const channelRepo = new CouncilChannelRepository(drizzleClient);
Expand Down Expand Up @@ -315,15 +321,7 @@ export function handleExecuteInstant(
const errBody = await bundleRes.text().catch(() => "");
log.debug("status", bundleRes.status);
log.debug("body", errBody);
log.error(
new Error(`HTTP ${bundleRes.status}`),
"provider bundle submission failed",
);
ctx.response.status = Status.BadGateway;
ctx.response.body = {
message: "Payment processing failed — provider rejected the bundle",
};
return;
throw providerBundleRejected(bundleRes.status, errBody);
}

const bundleData = await bundleRes.json().catch(() => ({}));
Expand All @@ -350,9 +348,10 @@ export function handleExecuteInstant(
});
if (!pollRes.ok) {
if (pollRes.status === 429) continue;
throw new Error(
`Bundle poll failed: ${pollRes.status} ${await pollRes.text()}`,
);
const errBody = await pollRes.text().catch(() => "");
log.debug("pollStatus", pollRes.status);
log.debug("pollBody", errBody);
throw providerBundleRejected(pollRes.status, errBody);
}
const pollData = await pollRes.json().catch(() => ({}));
const bundleStatus = pollData?.data?.status;
Expand All @@ -362,11 +361,18 @@ export function handleExecuteInstant(
break;
}
if (bundleStatus === "FAILED" || bundleStatus === "EXPIRED") {
throw new Error(`Bundle ${bundleId} ${bundleStatus}`);
throw bundleSettlementFailed({
bundleId,
outcome: bundleStatus,
failureDetail: pollData?.data?.failureDetail,
});
}
}
if (!settled) {
throw new Error(`Bundle ${bundleId} did not settle within 120 s`);
throw bundleSettlementFailed({
bundleId,
outcome: "did not settle within 120 s",
});
}
log.event("bundle settled on chain");
}
Expand Down Expand Up @@ -394,8 +400,18 @@ export function handleExecuteInstant(
};
} catch (error) {
log.error(error, "failed to execute instant payment");
ctx.response.status = Status.InternalServerError;
ctx.response.body = { message: "Failed to process payment" };
const platformError = PlatformError.fromUnknown(error, {
source: "@http/v1/pay/instant-execute",
details: "Failed to process the instant payment.",
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: platformError.message,
});
span.recordException(
error instanceof Error ? error : new Error(String(error)),
);
await PIPE_APIError(ctx, deps).run(platformError);
}
});
}
Expand Down
18 changes: 15 additions & 3 deletions src/http/v1/pay/instant-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
STELLAR_NETWORK_PASSPHRASE,
} from "@/config/env.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";
import { SpanStatusCode, withSpan } from "@/core/tracing.ts";
import { PlatformError } from "@/error/index.ts";
import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts";

const councilRepo = new CouncilRepository(drizzleClient);
const channelRepo = new CouncilChannelRepository(drizzleClient);
Expand Down Expand Up @@ -216,8 +218,18 @@ export function handlePrepareInstant(
};
} catch (error) {
log.error(error, "failed to prepare instant payment");
ctx.response.status = Status.InternalServerError;
ctx.response.body = { message: "Failed to prepare payment" };
const platformError = PlatformError.fromUnknown(error, {
source: "@http/v1/pay/instant-prepare",
details: "Failed to prepare the instant payment.",
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: platformError.message,
});
span.recordException(
error instanceof Error ? error : new Error(String(error)),
);
await PIPE_APIError(ctx, deps).run(platformError);
}
});
}
29 changes: 17 additions & 12 deletions src/http/v1/pay/instant-submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { TransactionRepository } from "@/persistence/drizzle/repository/transact
import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts";
import { getProviderJwt } from "@/core/service/provider-auth.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";
import { SpanStatusCode, withSpan } from "@/core/tracing.ts";
import { PlatformError } from "@/error/index.ts";
import { PIPE_APIError } from "@/http/pipelines/error-pipeline.ts";
import { providerBundleRejected } from "@/http/v1/pay/pay.errors.ts";

const councilRepo = new CouncilRepository(drizzleClient);
const channelRepo = new CouncilChannelRepository(drizzleClient);
Expand Down Expand Up @@ -134,15 +137,7 @@ export function handleSubmitInstant(
const errBody = await bundleRes.text().catch(() => "");
log.debug("status", bundleRes.status);
log.debug("body", errBody);
log.error(
new Error(`HTTP ${bundleRes.status}`),
"provider-platform bundle submission failed",
);
ctx.response.status = Status.BadGateway;
ctx.response.body = {
message: "Payment processing failed — provider rejected the bundle",
};
return;
throw providerBundleRejected(bundleRes.status, errBody);
}

const bundleData = await bundleRes.json().catch(() => ({}));
Expand Down Expand Up @@ -197,8 +192,18 @@ export function handleSubmitInstant(
};
} catch (error) {
log.error(error, "failed to submit instant payment");
ctx.response.status = Status.InternalServerError;
ctx.response.body = { message: "Failed to process payment" };
const platformError = PlatformError.fromUnknown(error, {
source: "@http/v1/pay/instant-submit",
details: "Failed to process the instant payment.",
});
span.setStatus({
code: SpanStatusCode.ERROR,
message: platformError.message,
});
span.recordException(
error instanceof Error ? error : new Error(String(error)),
);
await PIPE_APIError(ctx, deps).run(platformError);
}
});
}
172 changes: 172 additions & 0 deletions src/http/v1/pay/pay.errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { PlatformError } from "@/error/index.ts";
import type { APIErrorStatus } from "@/http/default-schemas.ts";

/**
* Error codes owned by the /pay/* payment slice. These are used only when the
* upstream provider-platform does not supply its own structured error `code`
* (in which case that provider/soroman code is forwarded verbatim instead).
*/
export enum PAY_ERROR_CODES {
PROVIDER_BUNDLE_REJECTED = "PAY_BUNDLE_001",
BUNDLE_SETTLEMENT_FAILED = "PAY_BUNDLE_002",
}

const source = "@http/v1/pay";

/**
* Statuses accepted by `errorStatusSchema` (default-schemas.ts). Any upstream
* status outside this set must be mapped before it reaches the wire.
*/
const ALLOWED_STATUSES: readonly number[] = [400, 401, 403, 404, 409, 429];

/**
* Maps an upstream provider-platform HTTP status onto a status allowed by
* pay-platform's `errorStatusSchema`.
* - An allowed 4xx passes through unchanged.
* - Any other 4xx (a provider-side rejection) collapses to 409 Conflict.
* - A 5xx / unreachable upstream collapses to 500.
*/
export function mapProviderStatus(providerStatus: number): APIErrorStatus {
if (ALLOWED_STATUSES.includes(providerStatus)) {
return providerStatus as APIErrorStatus;
}
if (providerStatus >= 400 && providerStatus < 500) return 409;
return 500;
}

type ProviderErrorBody = {
code?: unknown;
status?: unknown;
message?: unknown;
details?: unknown;
};

const asString = (value: unknown): string | undefined =>
typeof value === "string" && value.length > 0 ? value : undefined;

/**
* Parses a provider-platform error response body. Provider `PlatformError`s
* serialize as `{ code, status, message, details }`. Returns `undefined` when
* the body is missing or is not a JSON object.
*/
export function parseProviderError(
raw: string,
): ProviderErrorBody | undefined {
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
return parsed as ProviderErrorBody;
}
} catch {
// Not JSON — there is no structured identity to forward.
}
return undefined;
}

/**
* A bundle submit/poll returned a non-2xx from provider-platform. Forwards the
* provider's own error `code` and safe `message`/`details` through
* pay-platform's structured error system. The raw upstream body is kept only on
* `baseError` (for logs/traces) and never reaches the wire.
*/
export class PROVIDER_BUNDLE_REJECTED extends PlatformError {
constructor(args: {
providerStatus: number;
providerCode?: string;
providerMessage?: string;
providerDetails?: string;
baseError?: Error | unknown;
}) {
const status = mapProviderStatus(args.providerStatus);
const message = args.providerMessage ??
"The payment bundle was rejected by the provider.";
super({
source,
code: args.providerCode ?? PAY_ERROR_CODES.PROVIDER_BUNDLE_REJECTED,
message,
details: args.providerDetails,
baseError: args.baseError,
api: {
status,
message,
details: args.providerDetails,
},
});
}
}

/**
* Builds a {@link PROVIDER_BUNDLE_REJECTED} from a raw upstream response,
* preserving the cause chain on `baseError`.
*/
export function providerBundleRejected(
providerStatus: number,
rawBody: string,
): PROVIDER_BUNDLE_REJECTED {
const parsed = parseProviderError(rawBody);
return new PROVIDER_BUNDLE_REJECTED({
providerStatus,
providerCode: asString(parsed?.code),
providerMessage: asString(parsed?.message),
providerDetails: asString(parsed?.details),
baseError: new Error(
`provider-platform responded ${providerStatus}`,
rawBody ? { cause: new Error(rawBody) } : undefined,
),
});
}

/**
* An async bundle failed to settle on chain (poll returned FAILED/EXPIRED, or
* the settlement wait timed out). The on-chain identity lives in the bundle
* GET's `data.failureDetail = { code, source, message, name? }`.
*/
export class BUNDLE_SETTLEMENT_FAILED extends PlatformError {
constructor(args: {
bundleId: string;
outcome: string;
failureCode?: string;
failureMessage?: string;
baseError?: Error | unknown;
}) {
const message = args.failureMessage ??
`The payment bundle did not settle (${args.outcome}).`;
super({
source,
code: args.failureCode ?? PAY_ERROR_CODES.BUNDLE_SETTLEMENT_FAILED,
message,
baseError: args.baseError,
api: {
status: 409,
message,
},
});
}
}

type FailureDetail = {
code?: unknown;
source?: unknown;
message?: unknown;
name?: unknown;
};

/**
* Builds a {@link BUNDLE_SETTLEMENT_FAILED} from a bundle GET's
* `data.failureDetail`, preserving the cause chain on `baseError`.
*/
export function bundleSettlementFailed(args: {
bundleId: string;
outcome: string;
failureDetail?: FailureDetail;
}): BUNDLE_SETTLEMENT_FAILED {
const { bundleId, outcome, failureDetail } = args;
return new BUNDLE_SETTLEMENT_FAILED({
bundleId,
outcome,
failureCode: asString(failureDetail?.code),
failureMessage: asString(failureDetail?.message),
baseError: new Error(`bundle ${bundleId} ${outcome}`),
});
}
Loading