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
55 changes: 55 additions & 0 deletions .github/workflows/contracts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
test-and-build:
name: cargo test + build (wasm32)
runs-on: ubuntu-latest
permissions:
contents: read
issues: write

defaults:
run:
Expand Down Expand Up @@ -56,6 +59,58 @@ jobs:
- name: cargo build (release wasm32)
run: cargo build -p token_transfer --target wasm32-unknown-unknown --release

- name: Install GitHub CLI
run: sudo apt-get update && sudo apt-get install -y gh

- name: Report WASM binary sizes
if: always()
env:
GH_TOKEN: ${{ github.token }}
run: |
set -e

# Collect sizes for all release WASMs
WASM_DIR="target/wasm32-unknown-unknown/release"
TABLE="| Contract | Size (bytes) | Size (KB) |\n|---|---|---|\n"
THRESHOLD_BYTES=102400 # 100 KB

FAILED=0
for wasm in "$WASM_DIR"/*.wasm; do
[ -f "$wasm" ] || continue
name=$(basename "$wasm")
bytes=$(wc -c < "$wasm")
kb=$(echo "scale=2; $bytes / 1024" | bc)
TABLE="${TABLE}| \`${name}\` | ${bytes} | ${kb} KB |\n"
if [ "$bytes" -gt "$THRESHOLD_BYTES" ]; then
echo "::error file=${name}::WASM size ${bytes} bytes exceeds ${THRESHOLD_BYTES} byte limit"
FAILED=1
fi
done

# Write to job summary
printf "## WASM Binary Sizes\n\n${TABLE}\n" >> "$GITHUB_STEP_SUMMARY"

# Update-in-place PR comment
if [ "${{ github.event_name }}" = "pull_request" ]; then
BODY=$(printf "<!-- wasm-size-report -->\n## WASM Binary Sizes\n\n${TABLE}")
# Find existing comment
COMMENT_ID=$(gh api \
repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
--jq '.[] | select(.body | startswith("<!-- wasm-size-report -->")) | .id' \
2>/dev/null | head -1) || true
if [ -n "$COMMENT_ID" ]; then
gh api --method PATCH \
repos/${{ github.repository }}/issues/comments/"$COMMENT_ID" \
-f body="$BODY" || true
else
gh api --method POST \
repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
-f body="$BODY" || true
fi
fi

exit $FAILED

clippy:
name: cargo clippy (wasm32)
runs-on: ubuntu-latest
Expand Down
13 changes: 13 additions & 0 deletions apps/ai_agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ dependencies = [
"weaviate-client>=4.0.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
"pytest-mock>=3.14.0",
"pytest-cov>=5.0.0",
]

[dependency-groups]
dev = [
"pytest>=8.0.0",
Expand All @@ -19,3 +27,8 @@ dev = [

[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
addopts = "--cov=. --cov-report=term-missing --cov-config=pyproject.toml"

[tool.coverage.run]
omit = ["tests/*"]
30 changes: 30 additions & 0 deletions apps/ai_agent/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Shared pytest fixtures for the ai_agent test suite."""

import os
import pytest
from fastapi.testclient import TestClient


@pytest.fixture(autouse=True)
def set_openai_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure OPENAI_API_KEY is always set so _openai_client() doesn't 500."""
monkeypatch.setenv("OPENAI_API_KEY", "test-key")


@pytest.fixture()
def client() -> TestClient:
"""FastAPI TestClient for the main app."""
from main import app
return TestClient(app)


@pytest.fixture()
def mock_openai(mocker):
"""Patch the OpenAI client used inside main.py."""
return mocker.patch("main.OpenAI")


@pytest.fixture()
def mock_weaviate(mocker):
"""Patch weaviate.connect_to_local used inside main.py."""
return mocker.patch("main.weaviate.connect_to_local")
2 changes: 2 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { conversationsRouter } from './routes/conversations.js';
import { messagesRouter } from './routes/messages.js';
import { usersRouter } from './routes/users.js';
import { devicesRouter } from './routes/devices.js';
import { treasuryRouter } from './routes/treasury.js';
import { requireAuth, type AuthRequest } from './middleware/auth.js';

const packageJson = JSON.parse(
Expand Down Expand Up @@ -49,6 +50,7 @@ app.use('/conversations', conversationsRouter);
app.use('/messages', messagesRouter);
app.use('/users', usersRouter);
app.use('/devices', devicesRouter);
app.use('/treasury', treasuryRouter);

app.get('/me', requireAuth, (req, res) => {
res.json({ user: (req as AuthRequest).auth });
Expand Down
37 changes: 37 additions & 0 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,42 @@ export const userDevices = pgTable(
],
);

// ─── Treasury Proposals (#130) ────────────────────────────────────────────────
//
// Synced from GROUP_TREASURY_CONTRACT_ID events by the Stellar listener.
// Idempotent upsert on (contractId, proposalId).

export const treasuryProposalStatusEnum = pgEnum('treasury_proposal_status', [
'active',
'approved',
'rejected',
'executed',
'expired',
]);

export const treasuryProposals = pgTable(
'treasury_proposals',
{
id: uuid('id').primaryKey().defaultRandom(),
contractId: text('contract_id').notNull(),
proposalId: text('proposal_id').notNull(),
conversationId: uuid('conversation_id').references(() => conversations.id, {
onDelete: 'set null',
}),
status: treasuryProposalStatusEnum('status').notNull().default('active'),
approvalsCount: integer('approvals_count').notNull().default(0),
rejectionsCount: integer('rejections_count').notNull().default(0),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('treasury_proposals_contract_proposal_idx').on(table.contractId, table.proposalId),
],
);

export type TreasuryProposal = typeof treasuryProposals.$inferSelect;
export type NewTreasuryProposal = typeof treasuryProposals.$inferInsert;

// ─── Relations ────────────────────────────────────────────────────────────────

export const usersRelations = relations(users, ({ many }) => ({
Expand All @@ -178,6 +214,7 @@ export const conversationsRelations = relations(conversations, ({ many }) => ({
members: many(conversationMembers),
messages: many(messages),
transfers: many(tokenTransfers),
treasuryProposals: many(treasuryProposals),
}));

export const conversationMembersRelations = relations(conversationMembers, ({ one }) => ({
Expand Down
14 changes: 13 additions & 1 deletion apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import { app } from './app.js';
import { redis as appRedis } from './lib/redis.js';
import { setSocketServer } from './lib/socket.js';
import { setOnline, setOffline, refreshPresence } from './services/presence.js';
import { buildRpcFetcher, runForever as runStellarListener } from './services/stellarListener.js';
import {
buildRpcFetcher,
buildTreasuryRpcFetcher,
runForever as runStellarListener,
} from './services/stellarListener.js';
import { loadEnv } from './config.js';

dotenv.config();
Expand Down Expand Up @@ -125,12 +129,20 @@ void attachRedisAdapter();
// chain connection logs but doesn't crash the API.
const stellarRpcUrl = process.env['STELLAR_RPC_URL'];
const tokenTransferContractId = process.env['TOKEN_TRANSFER_CONTRACT_ID'];
const groupTreasuryContractId = process.env['GROUP_TREASURY_CONTRACT_ID'];

if (stellarRpcUrl && tokenTransferContractId) {
void runStellarListener({
fetchEvents: buildRpcFetcher({
rpcUrl: stellarRpcUrl,
contractId: tokenTransferContractId,
}),
...(groupTreasuryContractId && {
fetchTreasuryEvents: buildTreasuryRpcFetcher({
rpcUrl: stellarRpcUrl,
contractId: groupTreasuryContractId,
}),
}),
});
} else {
console.log(
Expand Down
41 changes: 41 additions & 0 deletions apps/backend/src/routes/treasury.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Router } from 'express';
import { z } from 'zod';
import { requireAuth, type AuthRequest } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';

export const treasuryRouter = Router();

treasuryRouter.use(requireAuth);

const TTL_LEDGERS: Record<string, number> = {
'24h': 17280, // ~24 h at 5 s/ledger
'72h': 51840,
'7d': 120960,
};

const proposeSchema = z.object({
amount: z.number().positive(),
token: z.string().min(1),
recipient: z.string().regex(/^G[A-Z2-7]{55}$/, 'Invalid Stellar public key'),
ttl: z.enum(['24h', '72h', '7d']),
});

/**
* POST /treasury/propose
* Body: { amount, token, recipient, ttl }
* Stub: records intent and returns the ledger count for TTL.
*/
treasuryRouter.post('/propose', validate(proposeSchema), async (req, res) => {
const { amount, token, recipient, ttl } = req.body as z.infer<typeof proposeSchema>;
const auth = (req as AuthRequest).auth!;

// In production this would submit a multisig proposal transaction via Soroban SDK.
// For now, return the resolved ledger TTL so the frontend can display it.
res.status(201).json({
proposer: auth.userId,
amount,
token,
recipient,
ttlLedgers: TTL_LEDGERS[ttl],
});
});
Loading
Loading