Skip to content
Merged

changes #1073

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
46 changes: 46 additions & 0 deletions GAS_BENCHMARKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Soroban Gas Benchmarks

**Document baseline date:** 2026-09-29
**Last benchmark run:** None recorded
**Status:** No verified on-chain resource or fee measurements are currently available.

This is the living record for the NFT contract's resource use and fees. Values must be identified as measured, estimated, or unavailable; application-side constants are not chain measurements.

## Current Baseline

The backend currently seeds the following values in memory from [`GasMetricsService`](src/nft/gas-metrics.service.ts), and records the same fixed values from [`NftService`](src/nft/nft.service.ts). They are synthetic application metrics: there is no recorded Soroban simulation, successful transaction, measurement date, contract version, or network associated with them. The `gasUnits` values are application-defined and must not be interpreted as Stellar fees.

| Operation | CPU instructions | Memory bytes | App `gasUnits` | Inclusion / resource fee | Evidence and environment |
|---|---:|---:|---:|---|---|
| Mint | 1,250,000 | 45,000 | 15,200 | Not measured | Synthetic in-memory seed; no network recorded |
| Transfer | 890,000 | 32,000 | 11,400 | Not measured | Synthetic in-memory seed; no network recorded |
| Burn | Not measured | Not measured | Not measured | Not measured | No benchmark data found |
| Royalty claim | Not measured | Not measured | Not measured | Not measured | No benchmark data found |
| Metadata refresh | Not measured | Not measured | Not measured | Not measured | No benchmark data found |
| Freeze / unfreeze | Not measured | Not measured | Not measured | Not measured | No benchmark data found; requires the updated contract deployment |
| Collection queries | Not measured | Not measured | Not measured | Not applicable for simulation-only reads | No benchmark data found |

**Network and environment used for this baseline:** none. The two figures above are code constants, not testnet or production results. No Soroban RPC simulation or transaction submission was performed for this document. The repository's documented test environment is Stellar Testnet (`STELLAR_NETWORK=testnet`, `https://soroban-testnet.stellar.org`); that is the target for the first real benchmark run, not the source of the values above.

## Methodology

Run benchmarks against a deployed contract on Stellar Testnet before using the results as a production estimate.

1. Record the benchmark date, network, RPC endpoint, ledger/protocol version, contract ID, contract version and WASM hash, SDK/CLI version, and the state/setup required for each operation. Never include keys or secrets in this document.
2. Use the same transaction construction and Soroban RPC path as the client operation. Simulate each invocation and record the RPC's CPU instruction count, memory bytes, ledger footprint/read-write entry counts and byte counts, and `minResourceFee` when present.
3. For writes, submit successful testnet transactions using disposable or uniquely prepared test data. Record the transaction hash and actual `feeCharged` from the RPC separately from the simulated resource fee and the inclusion fee. Do not use the transaction builder's configured base fee as the actual Soroban fee.
4. For collection queries, run simulation-only reads against a fixed contract and ledger state. Record simulation resource costs; mark charged transaction fees as not applicable because no transaction is submitted.
5. Collect at least 30 successful samples per operation after 5 warm-up runs. Report sample count, median, p95, minimum, and maximum. Keep operation inputs and state comparable between before/after runs.
6. Repeat the same procedure after contract or SDK optimizations. Add a dated result row and retain the previous baseline; do not overwrite historical measurements. Link the change or benchmark run when available.

Suggested Soroban RPC measurements are `cpuInsns`, `memBytes`, footprint entry/byte counts, `minResourceFee`, and (for successful submitted transactions) `feeCharged`. Record units exactly as returned or convert to stroops explicitly; do not mix XLM, stroops, and application-defined units.

## Production Expectations

All real measurements should first be taken on Testnet and labeled **testnet**. Testnet values are comparative engineering data, not production guarantees: production fees and resource availability can differ with network protocol, ledger state, fee market, footprint, and contract version. Do not label any value a production measurement unless it was collected from a production transaction under an approved process. Any production estimate must be a separately labeled estimate with its assumptions; this document currently contains none.

## Change Log

| Date | Change | Network | Result |
|---|---|---|---|
| 2026-09-29 | Initial document; audited existing backend metrics and recorded their provenance | None; no chain run | Two synthetic application seeds recorded above; verified on-chain benchmark count: zero |
34 changes: 34 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,40 @@ Download a payout receipt PDF (only available for completed payouts).

All mint endpoints require `Authorization: Bearer <token>`.

### NFT Freeze Administration

Freeze administration endpoints require `x-admin-secret: <ADMIN_SECRET>`. The request body supplies the Stellar admin wallet that will sign the returned unsigned transaction; the NFT contract independently authorizes that wallet when the transaction is submitted.

#### `POST /admin/nfts/:tokenId/freeze`

Prepare an unsigned `freeze(token_id)` Soroban transaction for an existing NFT.

#### `POST /admin/nfts/:tokenId/unfreeze`

Prepare an unsigned `unfreeze(token_id)` Soroban transaction for a frozen NFT.

Both POST requests use this body:

```json
{
"adminAddress": "GADMIN6L6LGBKIWH3IRUZPVUY4COGEMW4J5YINOSPKO27YKTUUHTZF3"
}
```

**Response `200`:** Unsigned transaction XDR, action, token ID, contract ID, and network. The admin wallet must sign and submit the XDR before the on-chain state changes.

**Response `403`:** Missing or invalid `x-admin-secret`. The Soroban contract also enforces contract-admin authorization when the signing wallet submits the transaction.

**Response `404`:** Token ID is not minted.

**Response `409`:** Freeze requested for an already-frozen NFT, or unfreeze requested for an unfrozen NFT.

The Soroban contract source and WASM are maintained outside this repository. Deploy a contract version that stores the per-token freeze state, emits freeze/unfreeze events, exposes `is_frozen(token_id)`, and rejects transfers of frozen tokens before enabling these endpoints.

#### `GET /nfts/:tokenId/freeze-status`

Read the token's current on-chain freeze status. Returns `{ "tokenId": 42, "frozen": false }`; returns `404` when the token does not exist.

#### `POST /nft/prepare-mint`

Prepare a Soroban mint transaction XDR for client-side signing. The clip must:
Expand Down
49 changes: 49 additions & 0 deletions docs/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,55 @@ it('validation errors include "details" array', async () => {
Uses the `stellar-sdk` test utilities and a local Soroban sandbox (or testnet
when `STELLAR_NETWORK=testnet`):

Reusable invocation helpers live in `test/helpers/soroban-test.helper.ts`. Pass a
`SorobanTestContext` containing a Soroban RPC server, contract ID, source account,
and network passphrase. The helpers build and simulate calls without submitting
or signing transactions; use an isolated sandbox or testnet contract with the
required authorization and setup when asserting successful simulations.

```typescript
import StellarSdk from '@stellar/stellar-sdk';
import {
mint_test_clip,
set_test_royalty,
simulate_test_sale,
} from './helpers/soroban-test.helper';

const context = {
server,
contractId: process.env.SOROBAN_NFT_CONTRACT_ID!,
sourceAccount,
networkPassphrase: StellarSdk.Networks.TESTNET,
};

const mint = await mint_test_clip(context, {
tokenId: 42,
owner: creatorAddress,
metadata: 'ipfs://test-metadata',
royaltyBps: 1000,
});
const royalties = await set_test_royalty(context, {
tokenId: 42,
shares: [{ recipient: creatorAddress, bps: 9000 }, { recipient: platformAddress, bps: 1000 }],
});
const sale = await simulate_test_sale(context, {
tokenId: 42,
seller: creatorAddress,
buyer: buyerAddress,
salePrice: 1_000_000_000n,
royaltyBps: 1000,
});
```

Token IDs, metadata, royalty shares, sale price, and royalty basis points are
configurable. Each helper returns the built transaction, simulation response,
and invoked function name. `set_test_royalty` encodes recipient keys as Soroban
addresses rather than symbols. Simulations are independent and do not commit
ledger state: the example demonstrates call construction, not a stateful mint-
then-set-royalty-then-sale sequence. For state-dependent assertions, submit
signed calls to an isolated Soroban sandbox or test deployment and wait for each
transaction to commit.

```typescript
// test/soroban-bindings.integration-spec.ts
describe('SorobanBindings (integration)', () => {
Expand Down
13 changes: 11 additions & 2 deletions src/clips/nft-mint.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,11 @@ export class NftMintService {
return { clipId, mintAddress, mintedAt };
}

async prepareBurnTx(clipId: number, walletAddress: string) {
async prepareBurnTx(
clipId: number,
walletAddress: string,
refundRoyalties = false,
) {
const addrValidation = this.stellarService.validateAddress(walletAddress);
if (!addrValidation.valid)
throw new BadRequestException(addrValidation.message);
Expand All @@ -266,7 +270,11 @@ export class NftMintService {
JSON.stringify({
contract: contractId,
function: 'burn',
args: { owner: walletAddress, token_id: String(clipId) },
args: {
owner: walletAddress,
token_id: String(clipId),
refund_royalties: refundRoyalties,
},
network: this.stellarService.network,
}),
).toString('base64');
Expand All @@ -276,6 +284,7 @@ export class NftMintService {
owner: walletAddress,
contractId,
network: this.stellarService.network,
refundRoyalties,
};
}

Expand Down
41 changes: 41 additions & 0 deletions src/nft/admin-contract.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jest.mock('@stellar/stellar-sdk', () => {
const numericValue = Number(scVal.__xdr);
return Number.isNaN(numericValue) ? scVal.__xdr : numericValue;
}),
nativeToScVal: jest.fn((value: unknown) => value),
};

return { __esModule: true, default: sdkShape, ...sdkShape };
Expand All @@ -45,6 +46,8 @@ describe('AdminContractService.getCollectionInfo (Issue #679)', () => {
const stellarService = {
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
network: 'testnet',
validateAddress: jest.fn().mockReturnValue({ valid: true }),
};
const circuitBreakerService = {
execute: jest.fn((_config: unknown, fn: () => unknown) => fn()),
Expand Down Expand Up @@ -98,6 +101,7 @@ describe('AdminContractService.getTotalSupply', () => {
rpcUrl: 'https://soroban-testnet.stellar.org',
networkPassphrase: 'Test SDF Network ; September 2015',
network: 'testnet',
validateAddress: jest.fn().mockReturnValue({ valid: true }),
};
const circuitBreakerService = {
execute: jest.fn((_config: unknown, fn: () => unknown) => fn()),
Expand Down Expand Up @@ -126,4 +130,41 @@ describe('AdminContractService.getTotalSupply', () => {
InternalServerErrorException,
);
});

it('prepares a freeze transaction after confirming the token is not frozen', async () => {
mockSimulateTransaction
.mockResolvedValueOnce({ results: [{ xdr: 'clip-42' }] })
.mockResolvedValueOnce({ results: [{ xdr: '0' }] });

const result = await service.prepareTokenFreezeTx(42, 'GADMIN', true);

expect(result).toMatchObject({ action: 'freeze', tokenId: '42', xdr: 'mock-xdr' });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(2);
});

it('rejects a duplicate freeze request with conflict', async () => {
mockSimulateTransaction
.mockResolvedValueOnce({ results: [{ xdr: 'clip-42' }] })
.mockResolvedValueOnce({ results: [{ xdr: '1' }] });

await expect(service.prepareTokenFreezeTx(42, 'GADMIN', true)).rejects.toMatchObject({
status: 409,
});
});

it('prepares an unfreeze transaction for a frozen token', async () => {
mockSimulateTransaction
.mockResolvedValueOnce({ results: [{ xdr: 'clip-42' }] })
.mockResolvedValueOnce({ results: [{ xdr: '1' }] });

const result = await service.prepareTokenFreezeTx(42, 'GADMIN', false);

expect(result).toMatchObject({ action: 'unfreeze', tokenId: '42', xdr: 'mock-xdr' });
});

it('returns not found when querying the freeze status of a missing token', async () => {
mockSimulateTransaction.mockResolvedValue({ results: [] });

await expect(service.getFreezeStatus(42)).rejects.toMatchObject({ status: 404 });
});
});
83 changes: 80 additions & 3 deletions src/nft/admin-contract.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import StellarSdk from '@stellar/stellar-sdk';
import { StellarService } from '../stellar/stellar.service';
import {
Expand Down Expand Up @@ -69,6 +76,73 @@ export class AdminContractService {
};
}

async prepareTokenFreezeTx(
tokenId: number,
adminAddress: string,
frozen: boolean,
) {
this.validateTokenId(tokenId);
const status = await this.getFreezeStatus(tokenId);
if (status.frozen === frozen) {
throw new ConflictException(
`Token ${tokenId} is already ${frozen ? 'frozen' : 'unfrozen'}`,
);
}

const addressCheck = this.stellarService.validateAddress(adminAddress);
if (!addressCheck.valid) {
throw new InternalServerErrorException(
`Invalid admin wallet address: ${addressCheck.message}`,
);
}

const server = new StellarSdk.rpc.Server(this.stellarService.rpcUrl);
const sourceAccount = await this.circuitBreakerService.execute(
this.sorobanCircuitBreakerConfig,
async () => server.getAccount(adminAddress),
);
const contract = new StellarSdk.Contract(this.CONTRACT_ID);
const action = frozen ? 'freeze' : 'unfreeze';
const op = contract.call(
action,
StellarSdk.nativeToScVal(tokenId, { type: 'u32' }),
);
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
fee: '10000',
networkPassphrase: this.stellarService.networkPassphrase,
})
.addOperation(op)
.setTimeout(StellarSdk.TimeoutInfinite)
.build();

return {
xdr: tx.toXDR(),
action,
tokenId: tokenId.toString(),
contractId: this.CONTRACT_ID,
network: this.stellarService.network,
};
}

async getFreezeStatus(tokenId: number): Promise<{ tokenId: number; frozen: boolean }> {
this.validateTokenId(tokenId);
const token = await this.getClipId(tokenId);
if (token.clipId === null) {
throw new NotFoundException(`NFT ${tokenId} was not found`);
}

const frozen = await this.callViewFunction('is_frozen', [
StellarSdk.nativeToScVal(tokenId, { type: 'u32' }),
]);
return { tokenId, frozen: Boolean(frozen) };
}

private validateTokenId(tokenId: number): void {
if (!Number.isInteger(tokenId) || tokenId < 0 || tokenId > 0xffffffff) {
throw new BadRequestException('Token ID must be a valid u32');
}
}

async prepareRefreshMetadataTx(
tokenId: number,
adminAddress: string,
Expand Down Expand Up @@ -302,10 +376,13 @@ export class AdminContractService {
* Simulate a no-argument, no-auth contract view call and return its
* decoded native value.
*/
private async callViewFunction(fnName: string): Promise<unknown> {
private async callViewFunction(
fnName: string,
args: StellarSdk.xdr.ScVal[] = [],
): Promise<unknown> {
const server = new StellarSdk.rpc.Server(this.stellarService.rpcUrl);
const contract = new StellarSdk.Contract(this.CONTRACT_ID);
const op = contract.call(fnName);
const op = contract.call(fnName, ...args);
const dummyAccount = new StellarSdk.Account(
'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN',
'0',
Expand Down
29 changes: 29 additions & 0 deletions src/nft/admin-nft.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { NftFreezeAdminGuard } from './guards/nft-freeze-admin.guard';

describe('AdminGuard for NFT freeze operations', () => {
const originalAdminSecret = process.env.ADMIN_SECRET;
const guard = new NftFreezeAdminGuard();

afterEach(() => {
if (originalAdminSecret === undefined) {
delete process.env.ADMIN_SECRET;
} else {
process.env.ADMIN_SECRET = originalAdminSecret;
}
});

function context(secret?: string): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ headers: { 'x-admin-secret': secret } }),
}),
} as ExecutionContext;
}

it.each([undefined, 'wrong-secret'])('rejects an unauthorized admin call', (secret) => {
process.env.ADMIN_SECRET = 'expected-secret';

expect(() => guard.canActivate(context(secret))).toThrow(ForbiddenException);
});

Loading
Loading