Skip to content

Commit 849e3b0

Browse files
authored
Merge branch 'main' into fix/nft-contract-corrupted-merge
2 parents 6acc792 + 1d424a3 commit 849e3b0

11 files changed

Lines changed: 654 additions & 46 deletions

File tree

contracts/nft-contract/audit.md

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# ClipCash NFT Contract Security Audit Preparation
1+
# ClipCash NFT Contract Security Audit Preparation
22

3-
> **Contract:** `clips-nft-contract` v1.0.0
3+
> **Contract:** `clips-nft-contract` v1.1.0
44
> **Chain:** Stellar / Soroban (SDK 22.0.0)
55
> **Prepared:** 2026-07-29
66
> **Status:** ?? Ready for External Audit (findings documented below)
@@ -46,17 +46,17 @@ The backend's interaction surface with this contract is documented in Section 11
4646

4747
The `ClipsNftContract` is a Soroban-native NFT contract implementing:
4848

49-
- **Soulbound flag** tokens marked `is_soulbound = true` are non-transferable and cannot be approved for delegation.
50-
- **Admin-gated minting** only the stored `admin` address may call `mint`.
51-
- **Single-approval model** one approved spender per token; approval is consumed on `transfer_from`.
52-
- **Royalty BPS** configurable default royalty (010 000 BPS) stored in instance storage.
53-
- **Creator provenance** `creator` is set to the initial `to` address at mint and never changes.
49+
- **Soulbound flag** tokens marked `is_soulbound = true` are non-transferable and cannot be approved for delegation.
50+
- **Admin-gated minting** only the stored `admin` address may call `mint`.
51+
- **Single-approval model** one approved spender per token; approval is consumed on `transfer_from`.
52+
- **Royalty BPS** configurable default royalty (010 000 BPS) stored in instance storage.
53+
- **Creator provenance** `creator` is set to the initial `to` address at mint and never changes.
5454

5555
### Public Entry Points
5656

5757
| Function | Caller Auth | Admin-gated |
5858
|---|---|---|
59-
| `initialize(admin)` | none (first-caller wins) | |
59+
| `initialize(admin)` | none (first-caller wins) | |
6060
| `mint(to, token_id, clip_id, content_uri, is_soulbound)` | admin | YES |
6161
| `transfer(from, to, token_id)` | token owner (`from`) | NO |
6262
| `transfer_from(spender, from, to, token_id)` | approved spender | NO |
@@ -93,7 +93,7 @@ pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
9393

9494
**Finding AC-01 (Medium):** `initialize` does **not** call `admin.require_auth()`. Any address can call
9595
`initialize` with any `admin` value **before** the real deployer does. On Soroban this is a race condition
96-
whoever calls `initialize` first controls the contract.
96+
whoever calls `initialize` first controls the contract.
9797

9898
See Section 9.1 for the full finding and recommended fix.
9999

@@ -109,7 +109,7 @@ admin.require_auth();
109109
```
110110

111111
**Assessment:** PASS. Only the stored admin can invoke `mint`. Soroban's `require_auth()` enforces
112-
on-ledger authorization no off-chain bypass is possible.
112+
on-ledger authorization no off-chain bypass is possible.
113113

114114
---
115115

@@ -124,7 +124,7 @@ if token_data.owner != from { return Err(Error::Unauthorized); }
124124
```
125125

126126
**Assessment:** PASS. Dual check: (1) `from` must authorize the call, (2) `from` must be the stored owner.
127-
Both are required passing auth but wrong owner, or right owner without auth, both fail.
127+
Both are required passing auth but wrong owner, or right owner without auth, both fail.
128128

129129
---
130130

@@ -177,7 +177,7 @@ overflow-checks = true
177177
```
178178

179179
**Assessment:** PASS. All integer arithmetic in the release binary is compiled with overflow panics.
180-
Soroban's `panic = "abort"` means overflows abort the transaction cleanly no undefined behaviour.
180+
Soroban's `panic = "abort"` means overflows abort the transaction cleanly no undefined behaviour.
181181

182182
---
183183

@@ -192,7 +192,7 @@ pub fn increment_total_supply(env: &Env) {
192192
}
193193
```
194194

195-
**Assessment:** PASS. `u64` with `overflow-checks = true`. Overflow would abort at 2^64 - 1 tokens
195+
**Assessment:** PASS. `u64` with `overflow-checks = true`. Overflow would abort at 2^64 - 1 tokens
196196
practically unreachable.
197197

198198
---
@@ -212,7 +212,7 @@ Off-chain royalty calculation `price * bps / 10_000` is performed in the NestJS
212212
not in the contract itself.
213213

214214
**Assessment:** PASS. Bounds checked. Royalty application arithmetic is not enforced on-chain
215-
(by design Soroban does not natively execute royalty enforcement during transfers).
215+
(by design Soroban does not natively execute royalty enforcement during transfers).
216216

217217
---
218218

@@ -228,6 +228,33 @@ storage::get_owner_tokens(&env, &owner).len() as u64
228228

229229
---
230230

231+
### 4.5 Royalty Multiplication Overflow (Issue #689)
232+
233+
**Location:** `src/lib.rs``calculate_fractional_royalty`, `transfer_with_royalty`, `pay_royalty_with_asset`.
234+
235+
Three call sites multiply an unbounded, caller-supplied `sale_price` / `amount` by `royalty_bps`
236+
before dividing by `ROYALTY_BPS_MAX`. `overflow-checks = true` (4.1) would abort the whole
237+
transaction on overflow in a release build, but that's an opaque panic rather than a typed error,
238+
and the previous `pay_royalty_with_asset` used `saturating_mul`, which does *not* panic — it
239+
silently clamps to the type's max value and would have paid out a nonsensical royalty amount.
240+
All three sites now use `checked_mul` and return `Error::RoyaltyOverflow` explicitly:
241+
242+
| Function | Arithmetic type | Overflows only when `sale_price` / `amount` exceeds |
243+
|---|---|---|
244+
| `calculate_fractional_royalty` | `u128` | `u128::MAX / 10_000` (~3.4 × 10³⁴) |
245+
| `transfer_with_royalty` | `u64` | `u64::MAX / 10_000` (~1.84 × 10¹⁵ stroops, ~184M XLM) |
246+
| `pay_royalty_with_asset` | `i128` | `i128::MAX / 10_000` (~1.7 × 10³⁴) |
247+
248+
`royalty_bps` is bounds-checked to `ROYALTY_BPS_MAX` (10 000) before the multiplication in all
249+
three functions, so these are the only overflow conditions possible.
250+
251+
**Assessment:** PASS. Extreme values are rejected with a typed `Error::RoyaltyOverflow` instead of
252+
an opaque panic or a silently-clamped payout. Covered by
253+
`test_fractional_royalty_overflow_is_rejected`, `test_transfer_with_royalty_overflow_is_rejected`,
254+
and `test_pay_royalty_with_asset_overflow_is_rejected` in `src/test.rs`.
255+
256+
---
257+
231258
## 5. Reentrancy Risk Review
232259

233260
### 5.1 Soroban Execution Model
@@ -269,8 +296,8 @@ highest-risk entry points and should be the focus of access control review durin
269296

270297
### Privileged Deployment Sequence
271298

272-
1. `stellar contract deploy` obtains `CONTRACT_ID`
273-
2. `stellar contract invoke initialize --admin <ADMIN_ADDRESS>` **must be called atomically/immediately post-deploy**
299+
1. `stellar contract deploy` obtains `CONTRACT_ID`
300+
2. `stellar contract invoke initialize --admin <ADMIN_ADDRESS>` **must be called atomically/immediately post-deploy**
274301
3. Off-chain backend reads `CONTRACT_ID` from environment and routes mint calls through the admin key
275302

276303
> **FINDING AC-01 (Medium):** The gap between deploy and `initialize` is a frontrunning window.
@@ -323,7 +350,7 @@ bare `token_id` key for token data. No collision risk identified.
323350
| `mint` | `("mint", to)` | `(token_id, is_soulbound)` | PASS |
324351
| `transfer` | `("transfer", from, to)` | `token_id` | PASS |
325352
| `approve` | `("approve", owner, spender)` | `token_id` | PASS |
326-
| `set_default_royalty_bps` | | | MISSING |
353+
| `set_default_royalty_bps` | | | MISSING |
327354

328355
**FINDING EV-01 (Low):** `set_default_royalty_bps` does not emit an event. Off-chain indexers
329356
cannot detect royalty changes without polling. A `royalty_updated` event should be added.
@@ -338,7 +365,7 @@ cannot detect royalty changes without polling. A `royalty_updated` event should
338365
observes the deploy transaction can immediately call `initialize` with a malicious admin address
339366
before the legitimate deployer does.
340367

341-
**Impact:** Total contract takeover malicious admin controls minting.
368+
**Impact:** Total contract takeover malicious admin controls minting.
342369

343370
**Likelihood:** Low in practice (requires monitoring the mempool and acting faster than the deployer),
344371
but non-zero on high-traffic networks.
@@ -358,7 +385,7 @@ pub fn initialize(env: Env, admin: Address) -> Result<(), Error> {
358385

359386
Alternatively, bundle deploy + initialize in one transaction using `stellar contract deploy --invoke-args`.
360387

361-
**Status:** OPEN requires code change before mainnet deployment.
388+
**Status:** OPEN requires code change before mainnet deployment.
362389

363390
---
364391

@@ -381,7 +408,7 @@ pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> {
381408
}
382409
```
383410

384-
**Status:** DEFERRED acceptable for v1 if admin key is a hardware-secured multisig.
411+
**Status:** DEFERRED acceptable for v1 if admin key is a hardware-secured multisig.
385412

386413
---
387414

@@ -399,7 +426,7 @@ entries may be archived, making the contract non-functional until restored.
399426
- Add `env.storage().persistent().extend_ttl(&key, MIN_TTL, MAX_TTL)` on every `set_token` and `set_owner_token`.
400427
- Operate a keepalive bot that periodically calls a no-op function to extend the instance TTL.
401428

402-
**Status:** OPEN critical for long-term mainnet operation.
429+
**Status:** OPEN critical for long-term mainnet operation.
403430

404431
---
405432

@@ -420,7 +447,7 @@ pub fn emit_royalty_updated(env: &Env, old_bps: u32, new_bps: u32) {
420447
}
421448
```
422449

423-
**Status:** OPEN low risk, should be resolved before mainnet for transparency.
450+
**Status:** OPEN low risk, should be resolved before mainnet for transparency.
424451

425452
---
426453

@@ -432,7 +459,7 @@ with no format validation.
432459
**Impact:** An admin could mint tokens with empty URIs or malformed URIs. Integrity depends entirely
433460
on the off-chain backend enforcing URI format.
434461

435-
**Status:** INFORMATIONAL acceptable for v1 with trusted admin.
462+
**Status:** INFORMATIONAL acceptable for v1 with trusted admin.
436463

437464
---
438465

@@ -445,7 +472,7 @@ a marketplace escrow address.
445472
**Impact:** Royalty recipient attribution could be incorrect if the off-chain system mints to a
446473
non-creator wallet.
447474

448-
**Status:** INFORMATIONAL a design decision. The backend must always pass the actual creator's
475+
**Status:** INFORMATIONAL a design decision. The backend must always pass the actual creator's
449476
wallet as `to`. Consider adding a separate `creator` parameter to `mint` for future flexibility.
450477

451478
---
@@ -478,20 +505,20 @@ The test suite (`src/test.rs`) covers **30 test cases** across the following sce
478505
The NestJS backend (`src/nft/`) interacts with this contract via the Stellar Soroban RPC.
479506
Key trust assumptions auditors should be aware of:
480507

481-
1. **Admin key custody** The private key corresponding to the `admin` address used in `initialize`
508+
1. **Admin key custody** The private key corresponding to the `admin` address used in `initialize`
482509
is held by the backend operator. It is never transmitted over the network; the backend signs
483510
transactions server-side. Auditors should verify `STELLAR_SECRET_KEY` is loaded from secrets
484511
management (not hardcoded in source or `.env`).
485512

486-
2. **`mint` invocation** The backend's `NftService.mintClip()` calls `mint` after validating
513+
2. **`mint` invocation** The backend's `NftService.mintClip()` calls `mint` after validating
487514
clip ownership and status via `NftMintGuard`. The guard is enforced at the HTTP layer, not
488515
on-chain. On-chain, only the admin auth check applies.
489516

490-
3. **Signature verification for `prepare-mint`** `MintSignatureVerificationService` verifies an
517+
3. **Signature verification for `prepare-mint`** `MintSignatureVerificationService` verifies an
491518
Ed25519 wallet signature before building the XDR. This prevents unauthorized users from
492519
preparing mint transactions on behalf of others but does **not** substitute for on-chain auth.
493520

494-
4. **Royalty enforcement** Royalty BPS is configured on-chain but royalty collection occurs
521+
4. **Royalty enforcement** Royalty BPS is configured on-chain but royalty collection occurs
495522
off-chain. Secondary marketplaces are not forced by the contract to honour royalties.
496523

497524
### Open Questions for Auditors
@@ -508,7 +535,7 @@ Key trust assumptions auditors should be aware of:
508535

509536
## 12. Audit Checklist
510537

511-
### Pre-Audit (Internal track before sending to auditors)
538+
### Pre-Audit (Internal track before sending to auditors)
512539

513540
- [x] Access control review complete
514541
- [x] Overflow handling review complete
@@ -518,17 +545,17 @@ Key trust assumptions auditors should be aware of:
518545
- [x] Event emission review complete
519546
- [x] Findings documented with severity ratings
520547
- [x] Test coverage mapped
521-
- [ ] **AC-01 fix merged** `admin.require_auth()` added to `initialize`
522-
- [ ] **ST-01/ST-02 fix merged** `extend_ttl` calls added to write functions
523-
- [ ] **EV-01 fix merged** `royalty_updated` event added to `set_default_royalty_bps`
548+
- [ ] **AC-01 fix merged** `admin.require_auth()` added to `initialize`
549+
- [ ] **ST-01/ST-02 fix merged** `extend_ttl` calls added to write functions
550+
- [ ] **EV-01 fix merged** `royalty_updated` event added to `set_default_royalty_bps`
524551
- [ ] Deploy scripts reviewed for atomic initialize pattern
525552
- [ ] Admin private key stored in secrets manager (not committed `.env` file)
526553
- [ ] Soroban SDK dependency pinned to exact version (no range specifiers)
527554
- [ ] Contract WASM size within Soroban deployment limits verified
528555

529556
### Auditor Review (complete during external audit)
530557

531-
- [ ] Access control all `require_auth()` call sites verified
558+
- [ ] Access control all `require_auth()` call sites verified
532559
- [ ] Storage key collision analysis complete
533560
- [ ] Event completeness verified
534561
- [ ] Arithmetic safety confirmed

contracts/nft-contract/bindings/clips-nft-contract.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Issue #682: Generate TypeScript Contract Bindings
44
*/
55

6+
import StellarSdk from '@stellar/stellar-sdk';
7+
68
export interface TokenData {
79
owner: string;
810
isSoulbound: boolean;
@@ -65,6 +67,51 @@ export class ClipsNftContractClient {
6567
return true;
6668
}
6769

70+
/**
71+
* Build (but do not sign or submit) a Soroban transaction that calls
72+
* `mint` on the deployed contract, returning its XDR for an external
73+
* signer to sign and submit (Issue #694).
74+
*
75+
* The contract's `mint` requires `admin.require_auth()` on-chain (see
76+
* `contracts/nft-contract/src/lib.rs`), so `sourceAddress` must be the
77+
* account that will sign the returned XDR — the contract's configured
78+
* admin wallet. See `examples/mint-from-backend.ts` for a runnable
79+
* end-to-end example, and `NftMintService.prepareMintTx` /
80+
* `POST /nfts/prepare-mint` for how the backend does the equivalent
81+
* for user-facing mints.
82+
*/
83+
async buildMintTransaction(params: {
84+
sourceAddress: string;
85+
to: string;
86+
tokenId: bigint;
87+
clipId: string;
88+
contentUri: string;
89+
isSoulbound: boolean;
90+
}): Promise<string> {
91+
const server = new StellarSdk.rpc.Server(this.config.rpcUrl);
92+
const sourceAccount = await server.getAccount(params.sourceAddress);
93+
94+
const contract = new StellarSdk.Contract(this.config.contractId);
95+
const op = contract.call(
96+
'mint',
97+
StellarSdk.Address.fromString(params.to).toScVal(),
98+
StellarSdk.nativeToScVal(params.tokenId, { type: 'u64' }),
99+
StellarSdk.nativeToScVal(params.clipId, { type: 'string' }),
100+
StellarSdk.nativeToScVal(params.contentUri, { type: 'string' }),
101+
StellarSdk.nativeToScVal(params.isSoulbound, { type: 'bool' }),
102+
);
103+
104+
const tx = new StellarSdk.TransactionBuilder(sourceAccount, {
105+
fee: '10000',
106+
networkPassphrase: this.config.networkPassphrase,
107+
})
108+
.addOperation(op)
109+
.setTimeout(StellarSdk.TimeoutInfinite)
110+
.build();
111+
112+
return tx.toXDR();
113+
}
114+
68115
/**
69116
* Mint multiple clip NFTs in a single transaction (Issue #671)
70117
*/

contracts/nft-contract/deploy-mainnet.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ fi
125125
echo " ✅ Contract deployed!"
126126
echo ""
127127

128+
# ── Step 3b: Query deployed contract version ─────────────────
129+
DEPLOYED_VERSION=$(stellar contract invoke \
130+
--id "$CONTRACT_ID" \
131+
--source-account "$DEPLOYER_SECRET" \
132+
--network "$NETWORK" \
133+
--rpc-url "$RPC_URL" \
134+
--network-passphrase "$NETWORK_PASSPHRASE" \
135+
-- version 2>/dev/null | tr -d '"' || echo "unknown")
136+
echo " Contract version: $DEPLOYED_VERSION"
137+
echo ""
138+
128139
# ── Step 4: Initialize contract ──────────────────────────────
129140
INIT_ADMIN="${ADMIN_ADDRESS:-$DEPLOYER_PUBLIC}"
130141
if [[ -n "$INIT_ADMIN" ]]; then
@@ -144,6 +155,7 @@ fi
144155
# ── Step 5: Output ────────────────────────────────────────────
145156
echo "════════════════════════════════════════════════════════"
146157
echo " CONTRACT_ID : $CONTRACT_ID"
158+
echo " VERSION : $DEPLOYED_VERSION"
147159
echo " NETWORK : Stellar Mainnet (public)"
148160
echo "════════════════════════════════════════════════════════"
149161
echo ""

contracts/nft-contract/deploy-testnet.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ fi
100100
echo " ✅ Contract deployed!"
101101
echo ""
102102

103+
# ── Step 3b: Query deployed contract version ─────────────────
104+
DEPLOYED_VERSION=$(stellar contract invoke \
105+
--id "$CONTRACT_ID" \
106+
--source-account "$DEPLOYER_SECRET" \
107+
--network "$NETWORK" \
108+
--rpc-url "$RPC_URL" \
109+
--network-passphrase "$NETWORK_PASSPHRASE" \
110+
-- version 2>/dev/null | tr -d '"' || echo "unknown")
111+
echo " Contract version: $DEPLOYED_VERSION"
112+
echo ""
113+
103114
# ── Step 4: Initialize contract ──────────────────────────────
104115
INIT_ADMIN="${ADMIN_ADDRESS:-$DEPLOYER_PUBLIC}"
105116
if [[ -n "$INIT_ADMIN" ]]; then
@@ -119,6 +130,7 @@ fi
119130
# ── Step 5: Output ────────────────────────────────────────────
120131
echo "════════════════════════════════════════════════════════"
121132
echo " CONTRACT_ID : $CONTRACT_ID"
133+
echo " VERSION : $DEPLOYED_VERSION"
122134
echo "════════════════════════════════════════════════════════"
123135
echo ""
124136

0 commit comments

Comments
 (0)