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/provider-platform",
"version": "0.6.30",
"version": "0.7.0",
"license": "MIT",
"exports": "./src/main.ts",
"tasks": {
Expand Down
1 change: 1 addition & 0 deletions deno.lock

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

20 changes: 10 additions & 10 deletions src/core/service/auth/challenge/store/create-challenge-db.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import { ProcessEngine } from "@fifo/convee";
import { drizzleClient } from "@/persistence/drizzle/config.ts";
import { ChallengeRepository } from "@/persistence/drizzle/repository/challenge.repository.ts";
import { UserRepository } from "@/persistence/drizzle/repository/user.repository.ts";
import { EntityRepository } from "@/persistence/drizzle/repository/entity.repository.ts";
import { AccountRepository } from "@/persistence/drizzle/repository/account.repository.ts";
import {
ChallengeStatus,
EntityStatus,
type NewAccount,
type NewChallenge,
type NewUser,
UserStatus,
type NewEntity,
} from "@/persistence/drizzle/entity/index.ts";
import type { ChallengeData } from "@/core/service/auth/challenge/types.ts";
import { logAndThrow } from "@/utils/error/log-and-throw.ts";
import * as E from "@/core/service/auth/challenge/store/error.ts";
import { withSpan } from "@/core/tracing.ts";

const challengeRepository = new ChallengeRepository(drizzleClient);
const userRepository = new UserRepository(drizzleClient);
const entityRepository = new EntityRepository(drizzleClient);
const accountRepository = new AccountRepository(drizzleClient);

export const P_CreateChallengeDB = ProcessEngine.create(
Expand All @@ -31,18 +31,18 @@ export const P_CreateChallengeDB = ProcessEngine.create(
challengeData.clientAccount,
);

let user: NewUser | undefined;
let entity: NewEntity | undefined;
if (!account) {
span.addEvent("creating_new_user_and_account");
user = await userRepository.create({
span.addEvent("creating_new_entity_and_account");
entity = await entityRepository.create({
id: crypto.randomUUID(),
status: UserStatus.UNVERIFIED,
} as NewUser);
status: EntityStatus.UNVERIFIED,
} as NewEntity);

account = await accountRepository.create({
id: challengeData.clientAccount,
type: "USER",
userId: user.id,
entityId: entity.id,
} as NewAccount);
} else {
span.addEvent("account_exists");
Expand Down
81 changes: 71 additions & 10 deletions src/core/service/bundle/add-bundle.process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,19 @@ import type { ClassifiedOperations } from "@/core/service/bundle/bundle.types.ts
import { logAndThrow } from "@/utils/error/log-and-throw.ts";
import type { OperationsBundle } from "@/persistence/drizzle/entity/operations-bundle.entity.ts";
import {
AccountRepository,
EntityRepository,
OperationsBundleRepository,
SessionRepository,
UtxoRepository,
} from "@/persistence/drizzle/repository/index.ts";
import { EntityStatus } from "@/persistence/drizzle/entity/index.ts";
import { withSpan } from "@/core/tracing.ts";

// Repositories
const sessionRepository = new SessionRepository(drizzleClient);
const accountRepository = new AccountRepository(drizzleClient);
const entityRepository = new EntityRepository(drizzleClient);
const utxoRepository = new UtxoRepository(drizzleClient);
const operationsBundleRepository = new OperationsBundleRepository(
drizzleClient,
Expand Down Expand Up @@ -239,9 +244,27 @@ function persistSpendOperations(
/**
* Creates a SlotBundle from bundle data
*/
function aggregateBundleAmount(
classified: ClassifiedOperations,
): string | null {
const sum = (
list: Array<{ getAmount: () => bigint }>,
): bigint => list.reduce((acc, op) => acc + op.getAmount(), 0n);
if (classified.deposit.length > 0) return sum(classified.deposit).toString();
if (classified.withdraw.length > 0) {
return sum(classified.withdraw).toString();
}
// Sends: spend ops don't carry amounts (they reference UTXOs), but the
// create outputs do. Sum of created amounts ≈ amount being moved.
if (classified.create.length > 0) return sum(classified.create).toString();
return null;
}

function createSlotBundle(
bundleEntity: OperationsBundle,
classified: ClassifiedOperations,
entityName: string | null,
jurisdictions: string[],
): SlotBundle {
const weight = calculateBundleWeight(classified, MEMPOOL_WEIGHT_CONFIG);
const priorityScore = calculatePriorityScore({
Expand All @@ -262,6 +285,10 @@ function createSlotBundle(
priorityScore,
retryCount: bundleEntity.retryCount ?? 0,
lastFailureReason: bundleEntity.lastFailureReason ?? null,
ppPublicKey: bundleEntity.ppPublicKey ?? "",
entityName,
jurisdictions,
amount: aggregateBundleAmount(classified),
};
}

Expand All @@ -271,8 +298,6 @@ export const P_AddOperationsBundle = ProcessEngine.create(
(input: PostEndpointInput<typeof requestSchema>) => {
return withSpan("P_AddOperationsBundle", async (span) => {
const { operationsMLXDR, channelContractId } = input.body;
const jurisdictionFrom = input.body.jurisdictionFrom ?? null;
const jurisdictionTo = input.body.jurisdictionTo ?? null;
if (operationsMLXDR.length > BUNDLE_MAX_OPERATIONS) {
logAndThrow(
new E.TOO_MANY_OPERATIONS(
Expand All @@ -283,14 +308,47 @@ export const P_AddOperationsBundle = ProcessEngine.create(
}
const sessionData = input.ctx.state.session as JwtSessionData;

// Resolve channel client for on-chain reads (UTXO balances)
const channelCtx = await resolveChannelContext(channelContractId);
// URL-scoped: the route is /providers/:ppPublicKey/bundles. Extract
// the PP identifier here — the executor uses THIS specific PP, not
// a default or first-match across the platform.
const params = (input.ctx as unknown as {
params?: { ppPublicKey?: string };
}).params;
const ppPublicKey = params?.ppPublicKey;
if (!ppPublicKey) {
logAndThrow(new E.PP_PUBLIC_KEY_REQUIRED());
}
span.setAttribute("pp.publicKey", ppPublicKey);

// Resolve channel client for on-chain reads (UTXO balances). The
// resolver returns the PP-specific signer + channel client.
const channelCtx = await resolveChannelContext(
channelContractId,
ppPublicKey,
);
const channelClient = channelCtx.channelClient;

// 1. Session validation
span.addEvent("validating_session");
const userSession = await validateSession(sessionData.sessionId);

// 1b. Entity (KYC/KYB) gate — submitter must have an APPROVED entity.
span.addEvent("validating_entity_approval", {
"account.id": userSession.accountId,
});
const submitterAccount = await accountRepository.findById(
userSession.accountId,
);
const submitterEntity = submitterAccount
? await entityRepository.findById(submitterAccount.entityId)
: null;
if (
!submitterEntity ||
submitterEntity.status !== EntityStatus.APPROVED
) {
logAndThrow(new E.SUBMITTER_NOT_APPROVED(userSession.accountId));
}

// 2. Bundle ID generation and validation
span.addEvent("generating_bundle_id");
const bundleId = await generateBundleId(operationsMLXDR);
Expand Down Expand Up @@ -335,8 +393,7 @@ export const P_AddOperationsBundle = ProcessEngine.create(
operationsMLXDR: operationsMLXDR,
fee: feeCalculation.fee,
retryCount: 0,
jurisdictionFrom,
jurisdictionTo,
ppPublicKey,
updatedAt: new Date(),
updatedBy: userSession.accountId,
});
Expand All @@ -349,8 +406,7 @@ export const P_AddOperationsBundle = ProcessEngine.create(
ttl: calculateBundleTtl(),
operationsMLXDR: operationsMLXDR,
fee: feeCalculation.fee,
jurisdictionFrom,
jurisdictionTo,
ppPublicKey,
createdBy: userSession.accountId,
createdAt: new Date(),
});
Expand All @@ -375,9 +431,14 @@ export const P_AddOperationsBundle = ProcessEngine.create(
channelClient,
);

// 7. Create SlotBundle and add to Mempool
// 7. Create SlotBundle (with submitter entity info) and add to Mempool
span.addEvent("adding_to_mempool");
const slotBundle = createSlotBundle(bundleEntity, classified);
const slotBundle = createSlotBundle(
bundleEntity,
classified,
submitterEntity?.name ?? null,
submitterEntity?.jurisdictions ?? [],
);
const mempool = getMempool();
await mempool.addBundle(slotBundle);

Expand Down
96 changes: 96 additions & 0 deletions src/core/service/bundle/bundle.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,106 @@ export enum BUNDLE_ERROR_CODES {
BUNDLE_NOT_FOUND = "BND_008",
BUNDLE_ACCESS_FORBIDDEN = "BND_009",
TOO_MANY_OPERATIONS = "BND_010",
SUBMITTER_NOT_APPROVED = "BND_011",
PP_PUBLIC_KEY_REQUIRED = "BND_012",
PP_NOT_FOUND = "BND_013",
PP_NOT_MEMBER_OF_CHANNEL = "BND_014",
}

const source = "@service/bundle";

/**
* Error thrown when the request has no ppPublicKey path param.
*/
export class PP_PUBLIC_KEY_REQUIRED
extends PlatformError<Record<never, never>> {
constructor() {
super({
source,
code: BUNDLE_ERROR_CODES.PP_PUBLIC_KEY_REQUIRED,
message: "PP public key is required",
details:
"Bundle submission requires the route /providers/:ppPublicKey/bundles. No default PP exists.",
api: {
status: 400,
message: "PP public key is required",
details:
"Submit the bundle to /api/v1/providers/<ppPublicKey>/bundles. The PP must be specified explicitly.",
},
meta: {},
});
}
}

/**
* Error thrown when the addressed PP doesn't exist or isn't active.
*/
export class PP_NOT_FOUND extends PlatformError<{ ppPublicKey: string }> {
constructor(ppPublicKey: string) {
super({
source,
code: BUNDLE_ERROR_CODES.PP_NOT_FOUND,
message: "PP not found",
details:
`No active Privacy Provider was found with public key '${ppPublicKey}'.`,
api: {
status: 404,
message: "PP not found",
details:
`The PP '${ppPublicKey}' does not exist or isn't active on this provider-platform.`,
},
meta: { ppPublicKey },
});
}
}

/**
* Error thrown when the addressed PP isn't a member of the bundle's channel.
*/
export class PP_NOT_MEMBER_OF_CHANNEL extends PlatformError<{
ppPublicKey: string;
channelContractId: string;
}> {
constructor(ppPublicKey: string, channelContractId: string) {
super({
source,
code: BUNDLE_ERROR_CODES.PP_NOT_MEMBER_OF_CHANNEL,
message: "PP is not a member of the channel",
details:
`PP '${ppPublicKey}' has no active membership that includes channel '${channelContractId}'.`,
api: {
status: 403,
message: "PP is not a member of the channel",
details:
"The addressed Privacy Provider is not authorized to process bundles for this channel.",
},
meta: { ppPublicKey, channelContractId },
});
}
}

/**
* Error thrown when the submitter has no APPROVED entity record.
*/
export class SUBMITTER_NOT_APPROVED extends PlatformError<{ pubkey: string }> {
constructor(pubkey: string) {
super({
source,
code: BUNDLE_ERROR_CODES.SUBMITTER_NOT_APPROVED,
message: "Submitter not approved",
details:
`No APPROVED entity record exists for the submitter pubkey '${pubkey}'. Submit KYC/KYB info via POST /api/v1/entities first.`,
api: {
status: 403,
message: "Submitter not approved",
details:
"The submitter has not completed KYC/KYB. POST your entity info to /api/v1/entities before submitting bundles.",
},
meta: { pubkey },
});
}
}

/**
* Error thrown when session is invalid or not found
*/
Expand Down
8 changes: 8 additions & 0 deletions src/core/service/bundle/bundle.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export type SlotBundle = {
priorityScore: number;
retryCount: number;
lastFailureReason?: string | null;
/** PP that owns this bundle (URL-scoped at submission). */
ppPublicKey: string;
/** Submitter entity display name (looked up from createdBy → account → entity). */
entityName: string | null;
/** Submitter entity's jurisdictions. */
jurisdictions: string[];
/** Aggregated bundle amount in stroops (sum of dominant op kind). */
amount: string | null;
};

/**
Expand Down
Loading
Loading