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
25 changes: 25 additions & 0 deletions src/payments/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,28 @@ or `?processor=`.
| `POST` | `/payments/processors/:name/enable` | **ADMIN** | — |
| `POST` | `/payments/processors/:name/disable` | **ADMIN** | — |

### Stellar convenience routes

The feature spec names Stellar-specific endpoints. These three aliases pin the
processor to Stellar — no `X-Payment-Processor` header needed, and
`PAYMENTS_DEFAULT_PROCESSOR` is ignored:

| Method | Path | Body | Maps to |
|---|---|---|---|
| `POST` | `/payments/stellar/create` | `CreatePaymentDto` | create, forced to Stellar |
| `POST` | `/payments/stellar/submit` | `StellarSubmitDto` | server-side sign **+** submit (or submit a client-signed payload) |
| `GET` | `/payments/stellar/status?id=<hash>` | — | status by on-chain transaction hash |

Because Stellar signs server-side, `submit` takes the created payment's
`unsignedTransaction`, signs it, and submits — so the create → submit flow needs
no separate sign step. Pass `signedPayload` instead to submit a client-signed
XDR as-is. `paymentId` travels in the body (there is no `:id` in the path).

> These are thin aliases over the generic endpoints, declared in a dedicated
> controller registered **before** the generic one so the static
> `/payments/stellar/{submit,status}` paths take precedence over the dynamic
> `/payments/:id/{submit,status}` routes (Express matches in registration order).

### Example

```bash
Expand Down Expand Up @@ -208,5 +230,8 @@ npm run test -- src/payments # unit + integration, fully offline
mocked Horizon `Server` / `HttpService`.
- `payments.integration.spec` — **side-by-side**: routing by header/env, two
processors isolated in one test, and disabling one leaving the other working.
- `stellar-payments.controller.spec` — the `/payments/stellar/*` aliases: pinning
to Stellar under a `grantfox` env default, server-side sign+submit, and the
route-precedence guarantee over the generic `/payments/:id/*` routes.

Network clients are always mocked/injected, so no test touches the network.
4 changes: 2 additions & 2 deletions src/payments/adapters/stellar/stellar.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
SignedTransaction,
SubmittedTransaction,
} from "../../interfaces/payment-processor.interface";
import { STELLAR_HORIZON_SERVER } from "./stellar.constants";
import { STELLAR_HORIZON_SERVER, STELLAR_PROCESSOR_NAME } from "./stellar.constants";

/** Config accepted by {@link StellarAdapter.initialize}. */
export interface StellarConfig {
Expand Down Expand Up @@ -61,7 +61,7 @@ export class StellarAdapter implements IPaymentProcessor<
PaymentRequest,
CreatedPayment
> {
readonly name = "stellar";
readonly name = STELLAR_PROCESSOR_NAME;
readonly displayName = "Stellar";
readonly capabilities: PaymentCapabilities = {
// Stellar has no native refund; we reverse the payment, and can send back a
Expand Down
7 changes: 7 additions & 0 deletions src/payments/adapters/stellar/stellar.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,10 @@ export const STELLAR_HORIZON_SERVER = "STELLAR_HORIZON_SERVER";

/** Default Horizon endpoint (Stellar testnet). */
export const DEFAULT_HORIZON_URL = "https://horizon-testnet.stellar.org";

/**
* Stable selector key for the Stellar processor. Single source of truth shared
* by {@link StellarAdapter.name} and the `/payments/stellar/*` alias controller,
* so the route prefix and the processor it pins to can never drift apart.
*/
export const STELLAR_PROCESSOR_NAME = "stellar";
34 changes: 34 additions & 0 deletions src/payments/dto/stellar-submit.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { IsNotEmpty, IsOptional, IsString } from "class-validator";

/**
* Body for `POST /payments/stellar/submit`.
*
* The generic `POST /payments/:id/submit` takes the payment id from the URL and
* requires an already-signed payload. The Stellar convenience route has no
* `:id` segment, so the id travels in the body, and — because Stellar signs
* server-side — it accepts EITHER:
*
* - `signedPayload` — a client-signed transaction XDR, submitted as-is; or
* - `unsignedTransaction` — the XDR returned by `create`, which the server
* signs (using `STELLAR_SIGNING_SECRET`) and then submits in a single call.
*
* Exactly one of the two must be supplied; the controller returns 400 otherwise.
*/
export class StellarSubmitDto {
/** Correlation id returned by `POST /payments/stellar/create`. */
@IsString()
@IsNotEmpty()
paymentId: string;

/** Client-signed transaction XDR. Mutually exclusive with `unsignedTransaction`. */
@IsOptional()
@IsString()
@IsNotEmpty()
signedPayload?: string;

/** Unsigned XDR from `create`; the server signs it before submitting. */
@IsOptional()
@IsString()
@IsNotEmpty()
unsignedTransaction?: string;
}
7 changes: 6 additions & 1 deletion src/payments/payments.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { PaymentProcessorFactory } from "./payment-processor.factory";
import { PaymentsController } from "./payments.controller";
import { PaymentsService } from "./payments.service";
import { PaymentProcessorRegistry } from "./registry/payment-processor.registry";
import { StellarPaymentsController } from "./stellar-payments.controller";

/**
* Wires the payment-processor plugin system.
Expand All @@ -30,7 +31,11 @@ import { PaymentProcessorRegistry } from "./registry/payment-processor.registry"
*/
@Module({
imports: [ConfigModule, HttpModule, DiscoveryModule],
controllers: [PaymentsController],
// StellarPaymentsController MUST precede PaymentsController: its static
// `payments/stellar/{submit,status}` routes would otherwise be shadowed by the
// generic dynamic `payments/:id/{submit,status}` routes (Express matches in
// registration order). See the note in stellar-payments.controller.ts.
controllers: [StellarPaymentsController, PaymentsController],
providers: [
PaymentProcessorRegistry,
PaymentProcessorFactory,
Expand Down
15 changes: 15 additions & 0 deletions src/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ export class PaymentsService {
return this.factory.resolve(selector).submitTransaction(signed);
}

/**
* Convenience composition of sign + submit for server-side-signing processors
* (e.g. Stellar): resolve the processor once, sign the created payment, then
* submit the signed result. Backs the `/payments/stellar/submit` alias so a
* create → submit flow needs no separate client sign step.
*/
async signAndSubmit(
created: CreatedPayment,
selector?: string,
): Promise<SubmittedTransaction> {
const processor = this.factory.resolve(selector);
const signed = await processor.signTransaction(created);
return processor.submitTransaction(signed);
}

getStatus(
paymentId: string,
selector?: string,
Expand Down
207 changes: 207 additions & 0 deletions src/payments/stellar-payments.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { HttpService } from "@nestjs/axios";
import { INestApplication } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { Test, TestingModule } from "@nestjs/testing";
import {
Account,
Keypair,
Networks,
TransactionBuilder,
} from "@stellar/stellar-sdk";
import { of } from "rxjs";
import request from "supertest";
import { JwtAuthGuard } from "src/core/auth/jwt.guard";
import { RolesGuard } from "src/common/guard/roles.guard";
import { createGlobalValidationPipe } from "src/common/pipes/validation.pipe";
import { STELLAR_HORIZON_SERVER } from "./adapters/stellar/stellar.constants";
import { PaymentsModule } from "./payments.module";

/**
* Covers the `/payments/stellar/*` convenience routes. The env default
* processor is deliberately set to **grantfox** so every passing assertion
* proves the routes pin to Stellar regardless of the default — and, for
* `submit`/`status`, that the static Stellar paths win over the generic
* dynamic `payments/:id/*` routes (the registration-order precedence).
*
* Both network clients are in-memory fakes: Stellar submit/status resolve to
* hash "STELLAR_TX"; the Grantfox fake resolves to "GF_TX". So a Stellar result
* on a Stellar route is unambiguous.
*/
describe("Stellar payments convenience routes", () => {
const signingKp = Keypair.random();
const payerKp = Keypair.random();
const destKp = Keypair.random();

let app: INestApplication;

const stellarServer = {
loadAccount: jest
.fn()
.mockResolvedValue(new Account(payerKp.publicKey(), "100")),
submitTransaction: jest
.fn()
.mockResolvedValue({ hash: "STELLAR_TX", successful: true }),
transactions: jest.fn().mockReturnValue({
transaction: () => ({
call: () => Promise.resolve({ hash: "STELLAR_TX", successful: true }),
}),
}),
};

// Grantfox fake — if any Stellar route ever fell through to the generic
// handler under the grantfox env default, these "GF_*" values would surface.
const httpService = {
post: jest.fn(() => of({ data: { id: "GF_PAY", status: "processing" } })),
get: jest.fn(() =>
of({
data: {
id: "GF_PAY",
status: "confirmed",
transactionHash: "GF_TX",
amount: "10",
},
}),
),
};

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
load: [
() => ({
// Default is grantfox on purpose — the Stellar routes must ignore it.
PAYMENTS_DEFAULT_PROCESSOR: "grantfox",
STELLAR_NETWORK_PASSPHRASE: Networks.TESTNET,
STELLAR_SIGNING_SECRET: signingKp.secret(),
GRANTFOX_API_URL: "https://api.grantfox.example",
GRANTFOX_API_KEY: "key",
}),
],
}),
PaymentsModule,
],
})
.overrideProvider(STELLAR_HORIZON_SERVER)
.useValue(stellarServer)
.overrideProvider(HttpService)
.useValue(httpService)
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();

app = moduleFixture.createNestApplication();
app.useGlobalPipes(createGlobalValidationPipe());
app.setGlobalPrefix("api/v1");
await app.init();
});

afterAll(async () => {
await app.close();
});

const createBody = {
amount: "10",
currency: "XLM",
destination: destKp.publicKey(),
source: payerKp.publicKey(),
idempotencyKey: "idem-stellar-alias",
};

const create = () =>
request(app.getHttpServer())
.post("/api/v1/payments/stellar/create")
.send(createBody);

it("POST /create pins Stellar even under a grantfox env default", async () => {
const res = await create().expect(201);

// Stellar returns an unsigned XDR + PENDING; Grantfox would be PROCESSING
// with no unsignedTransaction.
expect(res.body.status).toBe("PENDING");
expect(typeof res.body.unsignedTransaction).toBe("string");
});

it("POST /submit signs server-side and submits an unsigned XDR", async () => {
const created = (await create().expect(201)).body;

const res = await request(app.getHttpServer())
.post("/api/v1/payments/stellar/submit")
.send({
paymentId: created.paymentId,
unsignedTransaction: created.unsignedTransaction,
})
.expect(200);

expect(res.body.transactionHash).toBe("STELLAR_TX");
expect(res.body.status).toBe("CONFIRMED");
});

it("POST /submit accepts a client-signed payload and submits it as-is", async () => {
const created = (await create().expect(201)).body;

const signedPayload = TransactionBuilder.fromXdr(
created.unsignedTransaction,
Networks.TESTNET,
);
signedPayload.sign(signingKp);

const res = await request(app.getHttpServer())
.post("/api/v1/payments/stellar/submit")
.send({
paymentId: created.paymentId,
signedPayload: signedPayload.toXdr(),
})
.expect(200);

expect(res.body.transactionHash).toBe("STELLAR_TX");
expect(res.body.status).toBe("CONFIRMED");
});

it("POST /submit with neither payload is a 400", async () => {
await request(app.getHttpServer())
.post("/api/v1/payments/stellar/submit")
.send({ paymentId: "some-id" })
.expect(400);
});

it("GET /status?id= wins over the generic :id/status route (precedence)", async () => {
const res = await request(app.getHttpServer())
.get("/api/v1/payments/stellar/status")
.query({ id: "STELLAR_TX" })
.expect(200);

// If this had fallen through to `payments/:id/status` (id="stellar"), the
// grantfox env default would have produced "GF_TX".
expect(res.body.transactionHash).toBe("STELLAR_TX");
expect(res.body.status).toBe("CONFIRMED");
});

it("GET /status without an id is a 400", async () => {
await request(app.getHttpServer())
.get("/api/v1/payments/stellar/status")
.expect(400);
});

it("still routes the generic :id/status for non-'stellar' ids", async () => {
// "STELLAR_TX" ≠ "stellar", so this matches the dynamic route; forcing the
// stellar processor keeps the assertion about routing, not selection.
const res = await request(app.getHttpServer())
.get("/api/v1/payments/STELLAR_TX/status")
.set("x-payment-processor", "stellar")
.expect(200);

expect(res.body.transactionHash).toBe("STELLAR_TX");
});

it("rejects a payload that violates the DTO with 400", async () => {
await request(app.getHttpServer())
.post("/api/v1/payments/stellar/create")
.send({ amount: "not-a-number", currency: "XLM", destination: "x" })
.expect(400);
});
});
Loading
Loading