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
69 changes: 65 additions & 4 deletions packages/stellar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {

## Deterministic Contract Address Derivation

`deriveContractAddress(deployerPublicKey, salt, wasmHash)` computes the Soroban
`deriveContractAddress(deployerPublicKey, salt, wasmHash, networkPassphrase)` computes the Soroban
contract address that will be assigned when a contract is deployed with the given
parameters, without submitting any transaction.

Expand All @@ -64,19 +64,80 @@ guaranteed to match the address assigned at deployment time.
| `deployerPublicKey`| `string` | `G…` Stellar public key of the deploying account |
| `salt` | `Buffer \| string` | 32-byte deployment salt (Buffer or hex string) |
| `wasmHash` | `Buffer \| string` | 32-byte SHA-256 hash of the WASM binary |
| `networkPassphrase`| `string` | Network passphrase (e.g., `Networks.PUBLIC`, `Networks.TESTNET_FUTURE`, etc.) |

### Example

```ts
import { deriveContractAddress, verifyContractAddress } from '@craft/stellar';

const previewAddress = deriveContractAddress(deployerKey, salt, wasmHash);
import { Networks } from 'stellar-sdk';

// Current signature requires networkPassphrase to prevent cross-network address collisions
const previewAddress = deriveContractAddress(
deployerKey,
salt,
wasmHash,
Networks.PUBLIC_NETWORK_PASSPHRASE,
);
console.log('Pre-deployment address:', previewAddress);

// After deployment, verify the address matches
const isMatch = verifyContractAddress(deployerKey, salt, wasmHash, deployedAddress);
const isMatch = verifyContractAddress(
deployerKey,
salt,
wasmHash,
deployedAddress,
Networks.PUBLIC_NETWORK_PASSPHRASE,
);
```

### Breaking Changes (v1.x)

**Version 1.x introduced a breaking change**: both `deriveContractAddress` and `verifyContractAddress`
now require a `networkPassphrase` parameter as their final argument.

#### Migration Guide

**Before (pre-v1.x):**
```ts
const address = deriveContractAddress(deployerKey, salt, wasmHash);
const isValid = verifyContractAddress(deployerKey, salt, wasmHash, deployedAddress);
```

**After (v1.x+):**
```ts
import { Networks } from 'stellar-sdk';

const address = deriveContractAddress(
deployerKey,
salt,
wasmHash,
Networks.PUBLIC_NETWORK_PASSPHRASE,
);
const isValid = verifyContractAddress(
deployerKey,
salt,
wasmHash,
deployedAddress,
Networks.PUBLIC_NETWORK_PASSPHRASE,
);
```

#### Rationale

The `networkPassphrase` parameter is required to **prevent cross-network address collisions**.
Contract addresses are deterministic based on the deployer, salt, WASM hash, and **network**.
Without including the network passphrase in the derivation, the same parameters could produce
identical addresses on different networks (Mainnet, Testnet, etc.), leading to confusion and
potential security issues.

#### Available Network Passphrases

- `Networks.PUBLIC_NETWORK_PASSPHRASE` – Stellar public network
- `Networks.TESTNET_NETWORK_PASSPHRASE` – Stellar test network
- `Networks.FUTURENET_NETWORK_PASSPHRASE` – Stellar future network
- Custom passphrase string (for private networks)

---

## Type-Safe Contract Invocation Wrapper
Expand Down
130 changes: 130 additions & 0 deletions packages/stellar/src/multi-party-issuance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,133 @@ describe('regression #1101 – reject co-signer signatures against a different t
expect(result.ok).toBe(true);
});
});

// ---------------------------------------------------------------------------
// Session expiry boundary conditions
// ---------------------------------------------------------------------------

describe('Session expiry at exact boundary', () => {
it('addCoSignerSignature allows signature one moment before expiry', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);

// Use a time one millisecond before expiry
const timeBefore = session.expiresAt - 1;

const result = addCoSignerSignature(
session.id,
SIGNER_A.publicKey(),
signTxXdr(baseTxXdr, SIGNER_A),
NETWORK,
timeBefore,
);

expect(result.ok).toBe(true);
});

it('addCoSignerSignature allows signature at exact expiry moment', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);

// Use the exact expiry time
// At exactly expiresAt, isSessionExpired returns false (because it checks now > expiresAt)
const exactExpiry = session.expiresAt;

const result = addCoSignerSignature(
session.id,
SIGNER_A.publicKey(),
signTxXdr(baseTxXdr, SIGNER_A),
NETWORK,
exactExpiry,
);

expect(result.ok).toBe(true);
});

it('addCoSignerSignature rejects signature after expiry', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);

// Use a time one millisecond after expiry
const timeAfter = session.expiresAt + 1;

const result = addCoSignerSignature(
session.id,
SIGNER_A.publicKey(),
signTxXdr(baseTxXdr, SIGNER_A),
NETWORK,
timeAfter,
);

expect(result.ok).toBe(false);
expect(result.error).toMatch(/expired/i);
});

it('expireTimedOutSessions does not expire session one moment before boundary', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);

// Use a time one millisecond before expiry
const timeBefore = session.expiresAt - 1;
const expiredCount = expireTimedOutSessions(timeBefore);

expect(expiredCount).toBe(0);

// Verify session is still in pending state
const updated = getIssuanceSession(session.id);
expect(updated?.state).toBe('pending');
});

it('expireTimedOutSessions expires session at exact boundary', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);

// Use the exact expiry time
const exactExpiry = session.expiresAt;
const expiredCount = expireTimedOutSessions(exactExpiry);

expect(expiredCount).toBe(0); // not expired at exactly the boundary

// Verify session is still in pending state at boundary
const updated = getIssuanceSession(session.id);
expect(updated?.state).toBe('pending');
});

it('expireTimedOutSessions expires session after boundary', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);

// Use a time one millisecond after expiry
const timeAfter = session.expiresAt + 1;
const expiredCount = expireTimedOutSessions(timeAfter);

expect(expiredCount).toBe(1);

// Verify session transitioned to expired state
const updated = getIssuanceSession(session.id);
expect(updated?.state).toBe('expired');
});

it('addCoSignerSignature and expireTimedOutSessions agree at boundary', () => {
const baseTxXdr = buildBaseTxXdr();
const session = createIssuanceSession({ required: 2, total: 3 }, baseTxXdr);
const exactExpiry = session.expiresAt;

// At the exact boundary (now === expiresAt):
// - addCoSignerSignature should accept (isSessionExpired checks now > expiresAt, not >=)
const sigResult = addCoSignerSignature(
session.id,
SIGNER_A.publicKey(),
signTxXdr(baseTxXdr, SIGNER_A),
NETWORK,
exactExpiry,
);

// - expireTimedOutSessions should NOT expire (isSessionExpired also checks now > expiresAt)
const expiredCount = expireTimedOutSessions(exactExpiry);

// Both should agree: the session is NOT expired at exactly expiresAt
expect(sigResult.ok).toBe(true);
expect(expiredCount).toBe(0);
});
});
4 changes: 3 additions & 1 deletion packages/stellar/src/multi-party-issuance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,20 +140,22 @@ export function createIssuanceSession(config: MultiPartyConfig, baseTxXdr: strin
* @param signerPublicKey - Public key of the co-signer submitting the signature
* @param signedTxXdr - Transaction signed by this co-signer
* @param networkPassphrase - Stellar network passphrase for XDR parsing
* @param _now - Override current timestamp (for testing)
*/
export function addCoSignerSignature(
sessionId: string,
signerPublicKey: string,
signedTxXdr: string,
networkPassphrase: string,
_now: number = Date.now(),
): AddSignatureResult {
const session = sessionStore.get(sessionId);
if (!session) {
return { ok: false, error: `Session '${sessionId}' not found` };
}

// Expire sessions that exceeded the timeout
if (isSessionExpired(session, Date.now())) {
if (isSessionExpired(session, _now)) {
session.state = 'expired';
return { ok: false, error: 'Session has expired' };
}
Expand Down
107 changes: 107 additions & 0 deletions packages/stellar/src/soroban-budget-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,4 +437,111 @@ describe('trackContractBudget – precomputedSimulation (#1108)', () => {
expect(metrics[0].contractId).toBe(CONTRACT_ID);
expect(metrics[0].method).toBe('myMethod');
});
});

describe('Circular buffer metrics storage', () => {
it('preserves insertion order when buffer is below capacity', async () => {
const mockSimulate = vi.fn()
.mockResolvedValueOnce(makeSimulation('1000000', '512000'))
.mockResolvedValueOnce(makeSimulation('2000000', '1024000'))
.mockResolvedValueOnce(makeSimulation('3000000', '2048000'));

await trackContractBudget(CONTRACT_ID, 'method1', [], SOURCE_KEY, {}, mockSimulate);
await trackContractBudget(CONTRACT_ID, 'method2', [], SOURCE_KEY, {}, mockSimulate);
await trackContractBudget(CONTRACT_ID, 'method3', [], SOURCE_KEY, {}, mockSimulate);

const metrics = getBudgetMetrics();
expect(metrics).toHaveLength(3);
expect(metrics[0].method).toBe('method1');
expect(metrics[1].method).toBe('method2');
expect(metrics[2].method).toBe('method3');
});

it('returns empty array when no metrics recorded', () => {
clearBudgetMetrics();
const metrics = getBudgetMetrics();
expect(metrics).toEqual([]);
});

it('evicts oldest entry when buffer reaches capacity', async () => {
const mockSimulate = vi.fn().mockImplementation((_, method) =>
Promise.resolve(makeSimulation('1000000', '512000')),
);

// Push MAX_STORED_METRICS + 1 entries
for (let i = 0; i < 1001; i++) {
await trackContractBudget(CONTRACT_ID, `method${i}`, [], SOURCE_KEY, {}, mockSimulate);
}

const metrics = getBudgetMetrics();
expect(metrics).toHaveLength(1000);
// First entry should be method1 (method0 was evicted)
expect(metrics[0].method).toBe('method1');
// Last entry should be method1000
expect(metrics[999].method).toBe('method1000');
});

it('maintains correct ordering (newest last) after circular wrap', async () => {
const mockSimulate = vi.fn().mockImplementation((_, method) =>
Promise.resolve(makeSimulation('1000000', '512000')),
);

// Fill buffer beyond capacity to force wrap-around
for (let i = 0; i < 1005; i++) {
await trackContractBudget(CONTRACT_ID, `m${i}`, [], SOURCE_KEY, {}, mockSimulate);
}

const metrics = getBudgetMetrics();
// Should have exactly 1000 entries (capacity)
expect(metrics).toHaveLength(1000);
// First should be m5 (since m0-m4 were evicted in circular wrap)
expect(metrics[0].method).toBe('m5');
// Last should be m1004
expect(metrics[999].method).toBe('m1004');

// Verify strict order
for (let i = 0; i < metrics.length; i++) {
const expectedNum = 5 + i;
expect(metrics[i].method).toBe(`m${expectedNum}`);
}
});

it('clears all metrics and resets circular buffer state', async () => {
const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000000', '512000'));

await trackContractBudget(CONTRACT_ID, 'method1', [], SOURCE_KEY, {}, mockSimulate);
expect(getBudgetMetrics()).toHaveLength(1);

clearBudgetMetrics();
expect(getBudgetMetrics()).toHaveLength(0);

// Verify buffer is properly reset by adding new metrics
await trackContractBudget(CONTRACT_ID, 'method2', [], SOURCE_KEY, {}, mockSimulate);
const metrics = getBudgetMetrics();
expect(metrics).toHaveLength(1);
expect(metrics[0].method).toBe('method2');
});

it('handles sequential fills and clears correctly', async () => {
const mockSimulate = vi.fn().mockResolvedValue(makeSimulation('1000000', '512000'));

// Fill 10 entries
for (let i = 0; i < 10; i++) {
await trackContractBudget(CONTRACT_ID, `a${i}`, [], SOURCE_KEY, {}, mockSimulate);
}
expect(getBudgetMetrics()).toHaveLength(10);

// Clear
clearBudgetMetrics();
expect(getBudgetMetrics()).toHaveLength(0);

// Fill 5 more entries (should start from index 0 again)
for (let i = 0; i < 5; i++) {
await trackContractBudget(CONTRACT_ID, `b${i}`, [], SOURCE_KEY, {}, mockSimulate);
}
const metrics = getBudgetMetrics();
expect(metrics).toHaveLength(5);
expect(metrics[0].method).toBe('b0');
expect(metrics[4].method).toBe('b4');
});
});
Loading