From b71a1635202ac225b9d9710bbf0ef3685354f3a5 Mon Sep 17 00:00:00 2001 From: favour-GL Date: Thu, 25 Jun 2026 23:27:17 +0100 Subject: [PATCH] feat:git remote set-url origin git@github-favour:favour-GL/AssetsUp.git --- contracts/EVENTS.md | 352 +++++++++++++++++ contracts/INTERFACE.md | 533 ++++++++++++++++++++++++++ contracts/README.md | 191 +++++++++ contracts/scripts/deploy-mainnet.sh | 203 ++++++++++ contracts/scripts/deploy-testnet.sh | 187 +++++++++ contracts/scripts/upgrade-contract.sh | 178 +++++++++ contracts/types/assetsup.ts | 422 ++++++++++++++++++++ contracts/types/multisig_transfer.ts | 431 +++++++++++++++++++++ 8 files changed, 2497 insertions(+) create mode 100644 contracts/EVENTS.md create mode 100644 contracts/INTERFACE.md create mode 100644 contracts/README.md create mode 100644 contracts/scripts/deploy-mainnet.sh create mode 100644 contracts/scripts/deploy-testnet.sh create mode 100644 contracts/scripts/upgrade-contract.sh create mode 100644 contracts/types/assetsup.ts create mode 100644 contracts/types/multisig_transfer.ts diff --git a/contracts/EVENTS.md b/contracts/EVENTS.md new file mode 100644 index 000000000..78da31711 --- /dev/null +++ b/contracts/EVENTS.md @@ -0,0 +1,352 @@ +# AssetsUp — Contract Events Reference + +This document describes every event emitted by both Soroban contracts. The backend indexer (BE-64) subscribes to these events via the Soroban RPC `getEvents` method to keep its PostgreSQL read-model in sync without querying contract storage on every request. + +--- + +## Subscribing to Events (Node.js) + +```typescript +// backend/src/stellar/event-indexer.ts +import { SorobanRpc } from "@stellar/stellar-sdk"; + +const server = new SorobanRpc.Server(process.env.STELLAR_RPC_URL!); + +async function pollEvents(startLedger: number) { + const response = await server.getEvents({ + startLedger, + filters: [ + { + type: "contract", + contractIds: [ + process.env.ASSETSUP_CONTRACT_ID!, + process.env.MULTISIG_TRANSFER_CONTRACT_ID!, + ], + }, + ], + limit: 200, + }); + + for (const event of response.events) { + const [contractId, eventName] = event.topic.map(t => + // topic[0] is always the contract ID symbol, topic[1] is the event name + t.value().toString() + ); + await handleEvent(contractId, eventName, event.value, event.ledger); + } + + return response.latestLedger; +} + +async function handleEvent( + contractId: string, + name: string, + data: unknown, + ledger: number +) { + switch (name) { + case "asset_created": return onAssetCreated(data, ledger); + case "asset_updated": return onAssetUpdated(data, ledger); + case "asset_deleted": return onAssetDeleted(data, ledger); + case "proposal_created": return onProposalCreated(data, ledger); + case "proposal_signed": return onProposalSigned(data, ledger); + case "transfer_executed": return onTransferExecuted(data, ledger); + case "proposal_cancelled":return onProposalCancelled(data, ledger); + default: + console.warn(`Unknown event: ${name} from ${contractId}`); + } +} +``` + +--- + +## `assetsup` Contract Events + +### `asset_created` + +Emitted when a new asset is stored for the first time. + +**Topics:** `["asset_created"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `id` | `String` | `string` | The newly created asset ID | +| `name` | `String` | `string` | Asset name | +| `category` | `String` | `string` | Asset category | +| `department_id` | `String` | `string` | Owning department | +| `assigned_user_id` | `Address` | `string` | Initial user assignment | +| `status` | `Symbol` | `string` | Initial status (e.g. `"Active"`) | +| `created_by` | `Address` | `string` | Admin who created the record | +| `ledger_timestamp` | `u64` | `bigint` | On-chain creation time (seconds) | + +**Trigger:** `create_asset()` success. + +**Backend action:** Insert a new row in the `assets` table. + +```typescript +async function onAssetCreated(data: unknown, ledger: number) { + const { id, name, category, department_id, assigned_user_id, status, created_by } = + data as Record; + + await db.asset.upsert({ + where: { id: String(id) }, + create: { + id: String(id), name: String(name), category: String(category), + departmentId: String(department_id), + assignedUserId: String(assigned_user_id), + status: String(status), createdBy: String(created_by), + syncedAtLedger: ledger, + }, + update: {}, + }); +} +``` + +--- + +### `asset_updated` + +Emitted after any field on an asset is changed. + +**Topics:** `["asset_updated"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `id` | `String` | `string` | Asset ID | +| `updated_fields` | `Vec` | `string[]` | Names of fields that changed | +| `updated_by` | `Address` | `string` | Admin who made the change | +| `ledger_timestamp` | `u64` | `bigint` | Time of update | + +**Trigger:** `update_asset()` success. + +**Backend action:** Fetch the full asset record from the contract (or use the diff) and update the DB row. Because only changed field names are emitted (not their new values), the indexer should re-fetch the record via `get_asset()` for a full sync: + +```typescript +async function onAssetUpdated(data: unknown, ledger: number) { + const { id } = data as Record; + // Re-fetch from contract to get all current values + const asset = await assetsUpClient.getAsset(String(id)); + await db.asset.update({ where: { id: String(id) }, data: { ...mapAsset(asset), syncedAtLedger: ledger } }); +} +``` + +--- + +### `asset_deleted` + +Emitted when an asset is removed from contract storage. + +**Topics:** `["asset_deleted"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `id` | `String` | `string` | Deleted asset ID | +| `deleted_by` | `Address` | `string` | Admin who deleted it | +| `ledger_timestamp` | `u64` | `bigint` | Time of deletion | + +**Trigger:** `delete_asset()` success. + +**Backend action:** Soft-delete or hard-delete the `assets` table row. + +--- + +### `contract_upgraded` (assetsup) + +Emitted after a successful WASM upgrade. + +**Topics:** `["contract_upgraded"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | +|---|---|---| +| `new_wasm_hash` | `BytesN<32>` | `Buffer` (hex string) | +| `upgraded_by` | `Address` | `string` | +| `ledger_timestamp` | `u64` | `bigint` | + +**Backend action:** Log the upgrade event. No DB model change required. + +--- + +## `multisig_transfer` Contract Events + +### `proposal_created` + +Emitted when a new transfer proposal is opened. + +**Topics:** `["proposal_created"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `id` | `String` | `string` | New proposal ID | +| `asset_id` | `String` | `string` | The asset involved | +| `from_user` | `Address` | `string` | Current asset holder | +| `to_user` | `Address` | `string` | Proposed recipient | +| `required_signatures` | `u32` | `number` | Threshold | +| `signers` | `Vec
` | `string[]` | All required signers | +| `expires_at` | `u64` | `bigint` | Unix expiry timestamp | +| `proposed_by` | `Address` | `string` | Caller who opened the proposal | +| `ledger_timestamp` | `u64` | `bigint` | | + +**Trigger:** `create_proposal()` success. + +**Backend action:** Insert a row in the `transfer_proposals` table and send notifications to each signer. + +```typescript +async function onProposalCreated(data: unknown, ledger: number) { + const d = data as Record; + await db.transferProposal.create({ + data: { + id: String(d.id), + assetId: String(d.asset_id), + fromUser: String(d.from_user), + toUser: String(d.to_user), + requiredSignatures: Number(d.required_signatures), + signers: (d.signers as string[]), + expiresAt: new Date(Number(BigInt(String(d.expires_at))) * 1000), + status: "Pending", + syncedAtLedger: ledger, + }, + }); + // Notify signers via websocket / push + await notifySigners(d.signers as string[], String(d.id)); +} +``` + +--- + +### `proposal_signed` + +Emitted each time a signer casts their vote (approve or reject). + +**Topics:** `["proposal_signed"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `proposal_id` | `String` | `string` | | +| `signer` | `Address` | `string` | Who voted | +| `decision` | `Symbol` | `"Approve" \| "Reject"` | The vote | +| `signatures_collected` | `u32` | `number` | Running count of approvals so far | +| `required_signatures` | `u32` | `number` | Threshold | +| `new_status` | `Symbol` | `string` | `"Pending"` or `"Approved"` | +| `ledger_timestamp` | `u64` | `bigint` | | + +**Trigger:** `sign_proposal()` success. + +**Backend action:** Update the signer entry in DB. If `new_status == "Approved"`, mark the proposal as approved and optionally notify the initiator. + +```typescript +async function onProposalSigned(data: unknown, ledger: number) { + const d = data as Record; + await db.transferProposal.update({ + where: { id: String(d.proposal_id) }, + data: { + status: String(d.new_status), + syncedAtLedger: ledger, + }, + }); + await db.proposalSignature.upsert({ + where: { proposalId_signer: { proposalId: String(d.proposal_id), signer: String(d.signer) } }, + create: { proposalId: String(d.proposal_id), signer: String(d.signer), decision: String(d.decision) }, + update: { decision: String(d.decision) }, + }); +} +``` + +--- + +### `transfer_executed` + +Emitted when a fully-approved proposal is executed, finalising the asset ownership change. + +**Topics:** `["transfer_executed"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `proposal_id` | `String` | `string` | | +| `asset_id` | `String` | `string` | | +| `from_user` | `Address` | `string` | Previous owner | +| `to_user` | `Address` | `string` | New owner | +| `executed_by` | `Address` | `string` | Caller of `execute_transfer` | +| `ledger_timestamp` | `u64` | `bigint` | | + +**Trigger:** `execute_transfer()` success. + +**Backend action:** Update the proposal status to `Executed` AND update `assets.assigned_user_id` to `to_user`. This is the critical event that keeps the backend's asset table in sync after a transfer. + +```typescript +async function onTransferExecuted(data: unknown, ledger: number) { + const d = data as Record; + await db.$transaction([ + db.transferProposal.update({ + where: { id: String(d.proposal_id) }, + data: { status: "Executed", executedAt: new Date(), syncedAtLedger: ledger }, + }), + db.asset.update({ + where: { id: String(d.asset_id) }, + data: { assignedUserId: String(d.to_user), syncedAtLedger: ledger }, + }), + ]); +} +``` + +--- + +### `proposal_cancelled` + +Emitted when an admin cancels a pending proposal. + +**Topics:** `["proposal_cancelled"]` + +**Data fields:** + +| Field | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `proposal_id` | `String` | `string` | | +| `cancelled_by` | `Address` | `string` | Admin who cancelled | +| `ledger_timestamp` | `u64` | `bigint` | | + +**Trigger:** `cancel_proposal()` success. + +**Backend action:** Update proposal status to `Rejected` in the DB. + +--- + +### `contract_upgraded` (multisig_transfer) + +Same structure as the `assetsup` `contract_upgraded` event above. + +--- + +## Event Index by Contract + +### assetsup + +| Event name | Trigger function | DB impact | +|---|---|---| +| `asset_created` | `create_asset` | INSERT assets | +| `asset_updated` | `update_asset` | UPDATE assets | +| `asset_deleted` | `delete_asset` | DELETE / soft-delete assets | +| `contract_upgraded` | `upgrade` | Log only | + +### multisig_transfer + +| Event name | Trigger function | DB impact | +|---|---|---| +| `proposal_created` | `create_proposal` | INSERT transfer_proposals | +| `proposal_signed` | `sign_proposal` | UPDATE transfer_proposals + INSERT proposal_signatures | +| `transfer_executed` | `execute_transfer` | UPDATE transfer_proposals + UPDATE assets | +| `proposal_cancelled` | `cancel_proposal` | UPDATE transfer_proposals | +| `contract_upgraded` | `upgrade` | Log only | \ No newline at end of file diff --git a/contracts/INTERFACE.md b/contracts/INTERFACE.md new file mode 100644 index 000000000..e6887485f --- /dev/null +++ b/contracts/INTERFACE.md @@ -0,0 +1,533 @@ +# AssetsUp — Contract Interface Reference + +This document is the **contract between Soroban developers and the NestJS backend team (BE-60 – BE-64)**. It specifies every public function of both deployed contracts, their argument types in both Soroban XDR and TypeScript, and Node.js code examples using `@stellar/stellar-sdk v14.5.0`. + +--- + +## Contents + +1. [XDR ↔ TypeScript Type Map](#xdr--typescript-type-map) +2. [Setup](#setup) +3. [`assetsup` Contract](#assetsup-contract) +4. [`multisig_transfer` Contract](#multisig_transfer-contract) +5. [Error Handling](#error-handling) + +--- + +## XDR ↔ TypeScript Type Map + +| Soroban / XDR Type | TypeScript equivalent | Notes | +|---|---|---| +| `Address` | `string` | G… public key or C… contract ID | +| `String` | `string` | UTF-8, arbitrary length | +| `Symbol` | `string` | ≤ 32 chars, used as enum discriminant | +| `bool` | `boolean` | | +| `u32` | `number` | Fits safely in JS `number` | +| `i32` | `number` | | +| `u64` | `bigint` | Use `BigInt()` literals | +| `i64` | `bigint` | | +| `u128` | `bigint` | | +| `i128` | `bigint` | Financial amounts (stroops) | +| `BytesN<32>` | `Buffer` | Fixed 32-byte array (WASM hash) | +| `Option` | `T \| null` | | +| `Vec` | `T[]` | | +| `Map` | `Map` | | + +--- + +## Setup + +```bash +# Install the SDK (already in backend/package.json) +npm install @stellar/stellar-sdk@14.5.0 + +# Load contract IDs from the env file written by the deploy scripts +source contracts/.env.testnet # or .env.mainnet +``` + +```typescript +// backend/src/stellar/soroban.config.ts +import { Networks } from "@stellar/stellar-sdk"; +import { AssetsUpClient } from "../../contracts/types/assetsup"; +import { MultisigTransferClient } from "../../contracts/types/multisig_transfer"; + +const RPC_URL = process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; +const PASSPHRASE = process.env.STELLAR_NETWORK === "mainnet" + ? Networks.PUBLIC + : Networks.TESTNET; + +export const assetsUpClient = new AssetsUpClient({ + contractId: process.env.ASSETSUP_CONTRACT_ID!, + rpcUrl: RPC_URL, + networkPassphrase: PASSPHRASE, +}); + +export const multisigClient = new MultisigTransferClient({ + contractId: process.env.MULTISIG_TRANSFER_CONTRACT_ID!, + rpcUrl: RPC_URL, + networkPassphrase: PASSPHRASE, +}); +``` + +--- + +## `assetsup` Contract + +**Contract source:** `contracts/assetsup/` +**Contract ID env var:** `ASSETSUP_CONTRACT_ID` + +### `initialize` + +One-time setup. Must be called immediately after first deployment. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `admin` | `Address` | `string` | G… address that will be the contract admin | + +**Return type:** `()` (void) +**Access:** Anyone — but only succeeds once. Fails with `AlreadyInitialized` on repeat calls. +**When to call:** Called automatically by `deploy-testnet.sh` / `deploy-mainnet.sh`. The backend never calls this. + +--- + +### `get_asset` + +Fetch a single asset record by its ID. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `id` | `String` | `string` | Asset identifier | + +**Return type:** `Asset` struct (see `contracts/types/assetsup.ts`) +**Access:** Public — no auth required. +**Errors:** `AssetNotFound (4)` + +```typescript +// Node.js example +import { assetsUpClient } from "./soroban.config"; + +const asset = await assetsUpClient.getAsset("asset-001"); +console.log(asset.name, asset.status); +``` + +--- + +### `list_assets` + +Paginated list of all assets. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `page` | `u32` | `number` | Zero-based page index | +| `page_size` | `u32` | `number` | Records per page (max 100) | + +**Return type:** `AssetPage { items: Asset[], total: u32 }` +**Access:** Public. + +```typescript +// Node.js example +const { items, total } = await assetsUpClient.listAssets(0, 20); +console.log(`Showing ${items.length} of ${total} assets`); +``` + +--- + +### `get_assets_by_user` + +All assets currently assigned to a user. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `user` | `Address` | `string` | User's Stellar public key | + +**Return type:** `Vec` +**Access:** Public. + +```typescript +const assets = await assetsUpClient.getAssetsByUser("GABC...XYZ"); +``` + +--- + +### `get_assets_by_department` + +All assets belonging to a department. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `department_id` | `String` | `string` | Department identifier | + +**Return type:** `Vec` +**Access:** Public. + +--- + +### `get_admin` + +Return the current admin address. + +**Parameters:** none +**Return type:** `Address` → `string` +**Access:** Public. + +--- + +### `create_asset` + +Create a new asset record in contract storage. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `caller` | `Address` | `string` | Must match the contract admin | +| `payload` | `CreateAssetPayload` | `CreateAssetPayload` | See struct below | + +**`CreateAssetPayload` fields:** + +| Field | Soroban type | TypeScript type | +|---|---|---| +| `name` | `String` | `string` | +| `category` | `String` | `string` | +| `serial_number` | `String` | `string` | +| `description` | `Option` | `string \| null` | +| `department_id` | `String` | `string` | +| `location_id` | `String` | `string` | +| `assigned_user_id` | `Address` | `string` | +| `purchase_date` | `String` | `string` (ISO-8601) | +| `purchase_value` | `i128` | `bigint` (stroops) | +| `condition` | `Symbol` | `AssetCondition` enum | +| `status` | `Symbol` | `AssetStatus` enum | + +**Return type:** `String` (the new asset ID) +**Access:** Admin only. Soroban `require_auth()` is called on `caller`. +**Errors:** `Unauthorized (3)`, `AssetAlreadyExists (5)`, `InvalidInput (6)` + +```typescript +// Node.js example — full write flow +import { Keypair, SorobanRpc } from "@stellar/stellar-sdk"; +import { assetsUpClient } from "./soroban.config"; +import { AssetCondition, AssetStatus } from "../../contracts/types/assetsup"; + +const adminKeypair = Keypair.fromSecret(process.env.ADMIN_SECRET!); +const server = new SorobanRpc.Server(process.env.STELLAR_RPC_URL!); + +// 1. Fetch source account +const sourceAccount = await server.getAccount(adminKeypair.publicKey()); + +// 2. Build the transaction +const txBuilder = await assetsUpClient.buildCreateAsset( + adminKeypair.publicKey(), + { + name: "MacBook Pro 16\"", + category: "IT Equipment", + serial_number: "C02XG0JXJGH5", + description: "Assigned to engineering team", + department_id: "dept-engineering", + location_id: "loc-hq-floor2", + assigned_user_id: "GSTAFF...", + purchase_date: "2024-01-15", + purchase_value: BigInt(2500 * 1e7), // 2500 XLM in stroops + condition: AssetCondition.New, + status: AssetStatus.Active, + }, + sourceAccount +); + +// 3. Sign and submit +const tx = txBuilder.build(); +await server.prepareTransaction(tx); // sets resource fees +tx.sign(adminKeypair); +const result = await server.sendTransaction(tx); +console.log("Asset created, tx hash:", result.hash); +``` + +--- + +### `update_asset` + +Update fields on an existing asset. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `caller` | `Address` | `string` | Admin address | +| `id` | `String` | `string` | Asset to update | +| `payload` | `UpdateAssetPayload` | `UpdateAssetPayload` | Partial — only provided fields are changed | + +**Return type:** Updated `Asset` struct +**Access:** Admin only. +**Errors:** `Unauthorized (3)`, `AssetNotFound (4)` + +```typescript +const txBuilder = await assetsUpClient.buildUpdateAsset( + adminKeypair.publicKey(), + "asset-001", + { status: AssetStatus.UnderMaintenance, condition: AssetCondition.Fair }, + sourceAccount +); +``` + +--- + +### `delete_asset` + +Remove an asset from contract storage. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `caller` | `Address` | `string` | Admin address | +| `id` | `String` | `string` | Asset to delete | + +**Return type:** `bool` (`true` on success) +**Access:** Admin only. +**Errors:** `Unauthorized (3)`, `AssetNotFound (4)` + +--- + +### `upgrade` + +Replace the contract's WASM with a new version. Used by `upgrade-contract.sh`. + +| Parameter | Soroban type | TypeScript type | Description | +|---|---|---|---| +| `new_wasm_hash` | `BytesN<32>` | `Buffer` | Hash of the uploaded WASM | + +**Return type:** `()` (void) +**Access:** Admin only. +**When to call:** Only via `contracts/scripts/upgrade-contract.sh`. Never called from the application layer. + +--- + +## `multisig_transfer` Contract + +**Contract source:** `contracts/multisig_transfer/` +**Contract ID env var:** `MULTISIG_TRANSFER_CONTRACT_ID` + +### `initialize` + +One-time setup. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `admin` | `Address` | `string` | + +**Return type:** `()` — Called by the deploy scripts only. + +--- + +### `get_proposal` + +Fetch a single transfer proposal. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `id` | `String` | `string` | + +**Return type:** `TransferProposal` (see `contracts/types/multisig_transfer.ts`) +**Access:** Public. +**Errors:** `ProposalNotFound (4)` + +```typescript +const proposal = await multisigClient.getProposal("proposal-001"); +console.log(proposal.status, proposal.signers); +``` + +--- + +### `list_proposals_by_asset` + +All proposals (in any state) for a given asset. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `asset_id` | `String` | `string` | + +**Return type:** `Vec` +**Access:** Public. + +--- + +### `list_proposals_for_signer` + +All pending proposals where the given user is a required signer. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `signer` | `Address` | `string` | + +**Return type:** `Vec` +**Access:** Public. Useful for building a "my approvals" inbox in the UI. + +```typescript +const pending = await multisigClient.listProposalsForSigner("GSIGNER..."); +``` + +--- + +### `get_proposal_stats` + +Aggregate statistics (total, pending, approved, executed, rejected counts). + +**Parameters:** none +**Return type:** `ProposalStats` +**Access:** Public. + +--- + +### `has_signed` + +Check whether a specific address has already signed a proposal. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `proposal_id` | `String` | `string` | +| `signer` | `Address` | `string` | + +**Return type:** `bool` +**Access:** Public. + +--- + +### `create_proposal` + +Propose an asset transfer that requires multi-party approval. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `caller` | `Address` | `string` | +| `payload` | `CreateTransferPayload` | See struct below | + +**`CreateTransferPayload` fields:** + +| Field | Soroban type | TypeScript type | Notes | +|---|---|---|---| +| `asset_id` | `String` | `string` | Must exist in `assetsup` contract | +| `from_user` | `Address` | `string` | Current holder | +| `to_user` | `Address` | `string` | Recipient | +| `notes` | `Option` | `string \| null` | | +| `required_signatures` | `u32` | `number` | Must be ≤ `signers.length` | +| `signers` | `Vec
` | `string[]` | | +| `expires_at` | `u64` | `bigint` | Unix timestamp (seconds) | + +**Return type:** `String` (new proposal ID) +**Access:** Admin or the current `from_user` of the asset. +**Errors:** `Unauthorized (3)`, `InvalidInput (11)` + +```typescript +// Node.js example +const txBuilder = await multisigClient.buildCreateProposal( + adminKeypair.publicKey(), + { + asset_id: "asset-001", + from_user: "GCURRENT...", + to_user: "GNEW...", + notes: "Quarterly department reallocation", + required_signatures: 2, + signers: ["GMANAGER1...", "GMANAGER2...", "GFINANCE..."], + expires_at: BigInt(Math.floor(Date.now() / 1000) + 7 * 86400), // 7 days + }, + sourceAccount +); +``` + +--- + +### `sign_proposal` + +Cast an approve or reject vote on a proposal. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `signer` | `Address` | `string` | +| `proposal_id` | `String` | `string` | +| `decision` | `Symbol` | `SignerDecision` (`"Approve"` or `"Reject"`) | + +**Return type:** Updated `TransferProposal` +**Access:** Must be one of the listed signers. Soroban `require_auth()` on `signer`. +**Errors:** `SignerNotFound (7)`, `AlreadySigned (8)`, `ProposalExpired (10)`, `ProposalNotPending (6)` + +```typescript +import { SignerDecision } from "../../contracts/types/multisig_transfer"; + +const txBuilder = await multisigClient.buildSignProposal( + signerKeypair.publicKey(), + "proposal-001", + SignerDecision.Approve, + sourceAccount +); +const tx = txBuilder.build(); +await server.prepareTransaction(tx); +tx.sign(signerKeypair); +await server.sendTransaction(tx); +``` + +--- + +### `execute_transfer` + +Finalise an approved proposal, updating the asset's `assigned_user_id` in the `assetsup` contract. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `caller` | `Address` | `string` | +| `proposal_id` | `String` | `string` | + +**Return type:** Final `TransferProposal` with `status: Executed` +**Access:** Admin or proposal initiator. Only callable when `status == Approved`. +**Errors:** `InsufficientSignatures (9)`, `ProposalNotPending (6)`, `Unauthorized (3)` + +```typescript +const txBuilder = await multisigClient.buildExecuteTransfer( + adminKeypair.publicKey(), + "proposal-001", + sourceAccount +); +``` + +--- + +### `cancel_proposal` + +Reject and close a pending proposal. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `caller` | `Address` | `string` | +| `proposal_id` | `String` | `string` | + +**Return type:** `bool` +**Access:** Admin only. + +--- + +### `upgrade` + +Upgrade contract WASM. Called by `upgrade-contract.sh` only. + +| Parameter | Soroban type | TypeScript type | +|---|---|---| +| `new_wasm_hash` | `BytesN<32>` | `Buffer` | + +**Return type:** `()` +**Access:** Admin only. + +--- + +## Error Handling + +Soroban contract errors are returned as `ScError` XDR values. The backend should map them: + +```typescript +// backend/src/stellar/soroban-errors.ts +import { AssetsUpError } from "../../contracts/types/assetsup"; +import { MultisigTransferError } from "../../contracts/types/multisig_transfer"; + +export function parseSorobanError(err: unknown): string { + const msg = String(err); + if (msg.includes("Error(Contract, #4)")) return "Asset or proposal not found"; + if (msg.includes("Error(Contract, #3)")) return "Unauthorized — admin key required"; + if (msg.includes("Error(Contract, #8)")) return "Signer has already voted"; + if (msg.includes("Error(Contract, #10)")) return "Proposal has expired"; + return `Soroban contract error: ${msg}`; +} +``` + +All error codes are defined in `contracts/types/assetsup.ts` (`AssetsUpError`) and `contracts/types/multisig_transfer.ts` (`MultisigTransferError`). \ No newline at end of file diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 000000000..603f7effb --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,191 @@ +# AssetsUp — Soroban Contracts + +This directory contains the two Soroban smart contracts that power the AssetsUp asset-management platform, plus deployment tooling and documentation. + +``` +contracts/ +├── assetsup/ # Core asset registry contract (SC-01) +├── multisig_transfer/ # Multi-signature transfer workflow contract +├── scripts/ +│ ├── deploy-testnet.sh # Deploy both contracts to Stellar testnet +│ ├── deploy-mainnet.sh # Deploy both contracts to Stellar mainnet +│ └── upgrade-contract.sh # Upgrade a deployed contract to a new WASM +├── types/ +│ ├── assetsup.ts # TypeScript bindings for assetsup +│ └── multisig_transfer.ts # TypeScript bindings for multisig_transfer +├── INTERFACE.md # Full ABI reference + Node.js code examples +├── EVENTS.md # All emitted events + indexer code examples +├── .env.testnet # Auto-generated: testnet contract IDs (git-ignored) +└── .env.mainnet # Auto-generated: mainnet contract IDs (git-ignored) +``` + +--- + +## Prerequisites + +### 1. Rust + Wasm target + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +rustup target add wasm32-unknown-unknown +``` + +### 2. Stellar CLI + +```bash +cargo install --locked stellar-cli --features opt + +# Verify +stellar --version +``` + +### 3. Make scripts executable (one-time) + +```bash +chmod +x contracts/scripts/*.sh +``` + +--- + +## Deploying to Testnet + +```bash +# Deploy with a fresh auto-generated keypair (simplest for first-time dev setup) +./contracts/scripts/deploy-testnet.sh + +# Deploy with your own keypair (recommended for persistent deployments) +export STELLAR_DEPLOYER_SECRET=S...yoursecretkey... +./contracts/scripts/deploy-testnet.sh + +# Optionally set a different admin address (defaults to the deployer's public key) +export ADMIN_ADDRESS=G...youradminaddress... +./contracts/scripts/deploy-testnet.sh +``` + +After a successful run, contract IDs are written to `contracts/.env.testnet`: + +``` +ASSETSUP_CONTRACT_ID=C... +MULTISIG_TRANSFER_CONTRACT_ID=C... +STELLAR_NETWORK=testnet +STELLAR_RPC_URL=https://soroban-testnet.stellar.org +STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +``` + +Load these into the backend: + +```bash +# In backend/.env or your CI environment +source contracts/.env.testnet +``` + +--- + +## Deploying to Mainnet + +> **⚠ This costs real XLM. The script requires explicit confirmation before proceeding.** + +```bash +export STELLAR_DEPLOYER_SECRET=S...yourmainnetsecretkey... +export ADMIN_ADDRESS=G...youradminaddress... # optional, defaults to deployer +./contracts/scripts/deploy-mainnet.sh +``` + +The deployer account must be funded with sufficient XLM for transaction fees before running the script. + +--- + +## Upgrading a Deployed Contract + +Use this after modifying a contract's Rust source: + +```bash +# Upgrade assetsup on testnet +export STELLAR_DEPLOYER_SECRET=S...adminkey... +./contracts/scripts/upgrade-contract.sh assetsup testnet + +# Upgrade multisig_transfer on mainnet (will prompt for confirmation) +./contracts/scripts/upgrade-contract.sh multisig_transfer mainnet +``` + +The script: +1. Rebuilds the WASM +2. Uploads the new WASM to the Stellar ledger +3. Calls `upgrade(new_wasm_hash)` on the deployed contract +4. Updates the WASM hash in the relevant `.env` file + +The contract's storage and ID remain unchanged — only the executable code is replaced. + +--- + +## Building Without Deploying + +```bash +cd contracts +stellar contract build + +# Compiled WASMs land in: +# target/wasm32-unknown-unknown/release/assetsup.wasm +# target/wasm32-unknown-unknown/release/multisig_transfer.wasm +``` + +--- + +## Regenerating TypeScript Bindings + +After any contract ABI change, regenerate the TypeScript types: + +```bash +cd contracts + +# Build first +stellar contract build + +# Regenerate bindings +stellar contract bindings typescript \ + --wasm target/wasm32-unknown-unknown/release/assetsup.wasm \ + --network testnet \ + --output-dir types \ + --contract-name assetsup + +stellar contract bindings typescript \ + --wasm target/wasm32-unknown-unknown/release/multisig_transfer.wasm \ + --network testnet \ + --output-dir types \ + --contract-name multisig_transfer +``` + +Then manually re-add the `AssetsUpClient` / `MultisigTransferClient` helper classes from `types/assetsup.ts` and `types/multisig_transfer.ts`. + +--- + +## Running Tests + +```bash +cd contracts +cargo test +``` + +--- + +## Documentation + +| File | Contents | +|---|---| +| `INTERFACE.md` | Every public function: parameters, types, access control, Node.js examples | +| `EVENTS.md` | Every emitted event: fields, triggers, backend indexer code | +| `types/assetsup.ts` | TypeScript types + `AssetsUpClient` for the NestJS backend | +| `types/multisig_transfer.ts` | TypeScript types + `MultisigTransferClient` | + +--- + +## Environment Files + +`contracts/.env.testnet` and `contracts/.env.mainnet` are auto-generated by the deploy scripts and **must not be committed to version control** (add to `.gitignore`): + +```gitignore +contracts/.env.testnet +contracts/.env.mainnet +``` + +Pass them into your CI environment as secrets, or load them from a secrets manager. \ No newline at end of file diff --git a/contracts/scripts/deploy-mainnet.sh b/contracts/scripts/deploy-mainnet.sh new file mode 100644 index 000000000..5ebfe33b9 --- /dev/null +++ b/contracts/scripts/deploy-mainnet.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# ============================================================================= +# deploy-mainnet.sh — Deploy AssetsUp Soroban contracts to Stellar Mainnet +# +# Usage: +# STELLAR_DEPLOYER_SECRET=S... ./contracts/scripts/deploy-mainnet.sh +# +# Required environment variables: +# STELLAR_DEPLOYER_SECRET Secret key of the funded deployer account (S...) +# +# Optional environment variables: +# ADMIN_ADDRESS Address to set as contract admin (G...) +# Defaults to the deployer's public key +# +# Outputs: +# contracts/.env.mainnet Written with ASSETSUP_CONTRACT_ID and +# MULTISIG_TRANSFER_CONTRACT_ID +# +# WARNING: This deploys to STELLAR MAINNET. Real XLM will be consumed. +# Ensure the deployer account has sufficient XLM for fees. +# ============================================================================= + +set -euo pipefail + +# ── Colour helpers ──────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +success() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; } + +# ── Constants ───────────────────────────────────────────────────────────────── +NETWORK="mainnet" +NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +RPC_URL="https://soroban-mainnet.stellar.org" +ENV_FILE="$(dirname "$0")/../.env.mainnet" +CONTRACTS_DIR="$(dirname "$0")/.." + +# ── 1. Prerequisite checks ──────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}${RED}═══════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD}${RED} AssetsUp — Stellar MAINNET Deployment ${RESET}" +echo -e "${BOLD}${RED}═══════════════════════════════════════════════════════${RESET}" +echo "" +echo -e "${YELLOW} ⚠ WARNING: You are about to deploy to STELLAR MAINNET.${RESET}" +echo -e "${YELLOW} Real XLM will be consumed. This cannot be undone.${RESET}" +echo "" + +# Explicit confirmation gate +read -rp " Type 'deploy mainnet' to confirm: " CONFIRM +if [[ "$CONFIRM" != "deploy mainnet" ]]; then + echo "Aborted." + exit 0 +fi +echo "" + +if ! command -v stellar &>/dev/null; then + error "stellar CLI not found. Install it with:\n cargo install --locked stellar-cli --features opt" +fi + +info "stellar CLI: $(stellar --version)" + +# ── 2. Deployer keypair — REQUIRED on mainnet ───────────────────────────────── +if [[ -z "${STELLAR_DEPLOYER_SECRET:-}" ]]; then + error "STELLAR_DEPLOYER_SECRET must be set for mainnet deployments.\n export STELLAR_DEPLOYER_SECRET=S..." +fi + +info "Deriving deployer public key…" +# Try stellar CLI key address derivation first, fall back to python stellar-sdk +DEPLOYER_PUBLIC=$(stellar keys address "$STELLAR_DEPLOYER_SECRET" 2>/dev/null || \ + python3 -c " +from stellar_sdk import Keypair +kp = Keypair.from_secret('$STELLAR_DEPLOYER_SECRET') +print(kp.public_key) +" 2>/dev/null) || error "Could not derive public key. Verify STELLAR_DEPLOYER_SECRET is a valid Stellar secret key." + +ADMIN_ADDRESS="${ADMIN_ADDRESS:-$DEPLOYER_PUBLIC}" +info "Deployer public key : $DEPLOYER_PUBLIC" +info "Admin address : $ADMIN_ADDRESS" + +# ── 3. Check deployer account exists and has XLM ───────────────────────────── +info "Checking deployer account on mainnet…" +ACCOUNT_CHECK=$(curl -sf "https://horizon.stellar.org/accounts/${DEPLOYER_PUBLIC}" 2>/dev/null || echo "{}") +if echo "$ACCOUNT_CHECK" | grep -q '"status":404' 2>/dev/null || ! echo "$ACCOUNT_CHECK" | grep -q '"account_id"' 2>/dev/null; then + error "Deployer account ${DEPLOYER_PUBLIC} not found on mainnet.\n Fund it with XLM before deploying." +fi + +XLM_BALANCE=$(echo "$ACCOUNT_CHECK" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for b in data.get('balances', []): + if b['asset_type'] == 'native': + print(b['balance']) + break +" 2>/dev/null || echo "unknown") +info "Deployer XLM balance: ${XLM_BALANCE} XLM" + +# ── 4. Build all contracts ──────────────────────────────────────────────────── +info "Building all Soroban contracts for mainnet…" +cd "$CONTRACTS_DIR" +stellar contract build +success "Build complete." + +# ── Locate compiled WASMs ───────────────────────────────────────────────────── +ASSETSUP_WASM=$(find target/wasm32-unknown-unknown/release -name "assetsup*.wasm" | head -1) +MULTISIG_WASM=$(find target/wasm32-unknown-unknown/release -name "multisig_transfer*.wasm" | head -1) + +[[ -f "$ASSETSUP_WASM" ]] || error "assetsup WASM not found after build." +[[ -f "$MULTISIG_WASM" ]] || error "multisig_transfer WASM not found after build." + +info "assetsup WASM : $ASSETSUP_WASM" +info "multisig_transfer WASM: $MULTISIG_WASM" + +# ── Helper: deploy one contract ─────────────────────────────────────────────── +deploy_contract() { + local name="$1" + local wasm="$2" + + info "Deploying ${name} to mainnet…" + local contract_id + contract_id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + 2>&1 | tail -1) + + if [[ ! "$contract_id" =~ ^[A-Z0-9]{56}$ ]]; then + error "Unexpected output from 'stellar contract deploy' for ${name}:\n ${contract_id}" + fi + + success "${name} deployed: ${contract_id}" + echo "$contract_id" +} + +# ── 5. Deploy assetsup ──────────────────────────────────────────────────────── +ASSETSUP_CONTRACT_ID=$(deploy_contract "assetsup" "$ASSETSUP_WASM") + +# ── 6. Deploy multisig_transfer ─────────────────────────────────────────────── +MULTISIG_CONTRACT_ID=$(deploy_contract "multisig_transfer" "$MULTISIG_WASM") + +# ── 7. Initialise contracts ─────────────────────────────────────────────────── +info "Initialising assetsup contract (admin = ${ADMIN_ADDRESS})…" +stellar contract invoke \ + --id "$ASSETSUP_CONTRACT_ID" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- initialize \ + --admin "$ADMIN_ADDRESS" +success "assetsup initialised." + +info "Initialising multisig_transfer contract (admin = ${ADMIN_ADDRESS})…" +stellar contract invoke \ + --id "$MULTISIG_CONTRACT_ID" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- initialize \ + --admin "$ADMIN_ADDRESS" +success "multisig_transfer initialised." + +# ── 8. Write .env.mainnet ───────────────────────────────────────────────────── +info "Writing contract IDs to ${ENV_FILE}…" +cat > "$ENV_FILE" <&2; exit 1; } + +# ── Constants ───────────────────────────────────────────────────────────────── +NETWORK="testnet" +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +RPC_URL="https://soroban-testnet.stellar.org" +FRIENDBOT_URL="https://friendbot.stellar.org" +ENV_FILE="$(dirname "$0")/../.env.testnet" +CONTRACTS_DIR="$(dirname "$0")/.." + +# ── 1. Prerequisite checks ──────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}═══════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD} AssetsUp — Stellar Testnet Deployment ${RESET}" +echo -e "${BOLD}═══════════════════════════════════════════════════════${RESET}" +echo "" + +if ! command -v stellar &>/dev/null; then + error "stellar CLI not found. Install it with:\n cargo install --locked stellar-cli --features opt" +fi + +info "stellar CLI: $(stellar --version)" + +# ── 2. Deployer keypair ─────────────────────────────────────────────────────── +if [[ -z "${STELLAR_DEPLOYER_SECRET:-}" ]]; then + warn "STELLAR_DEPLOYER_SECRET not set — generating a fresh keypair for this deployment." + KEYPAIR_JSON=$(stellar keys generate --no-fund deployer-testnet-$$ 2>/dev/null || true) + # Use stellar keys show to extract values + STELLAR_DEPLOYER_SECRET=$(stellar keys show deployer-testnet-$$ --secret-key 2>/dev/null) + DEPLOYER_PUBLIC=$(stellar keys show deployer-testnet-$$ 2>/dev/null) + warn "Generated keypair — store the secret key securely if you want to reuse this deployer!" + echo -e " ${YELLOW}Public key: ${DEPLOYER_PUBLIC}${RESET}" + echo -e " ${YELLOW}Secret key: ${STELLAR_DEPLOYER_SECRET}${RESET}" +else + info "Using provided STELLAR_DEPLOYER_SECRET." + DEPLOYER_PUBLIC=$(stellar keys address "$STELLAR_DEPLOYER_SECRET" 2>/dev/null || \ + python3 -c " +from stellar_sdk import Keypair +kp = Keypair.from_secret('$STELLAR_DEPLOYER_SECRET') +print(kp.public_key) +" 2>/dev/null || \ + stellar keys show --secret-key "$STELLAR_DEPLOYER_SECRET" 2>/dev/null || \ + { error "Could not derive public key from STELLAR_DEPLOYER_SECRET. Ensure stellar-sdk or Python is available."; }) +fi + +ADMIN_ADDRESS="${ADMIN_ADDRESS:-$DEPLOYER_PUBLIC}" +info "Deployer public key : $DEPLOYER_PUBLIC" +info "Admin address : $ADMIN_ADDRESS" + +# ── 3. Fund via Friendbot ───────────────────────────────────────────────────── +info "Funding deployer account via Friendbot…" +FUND_RESPONSE=$(curl -sf "${FRIENDBOT_URL}?addr=${DEPLOYER_PUBLIC}" || true) +if echo "$FUND_RESPONSE" | grep -q '"successful":true' 2>/dev/null || \ + echo "$FUND_RESPONSE" | grep -q '"hash"' 2>/dev/null; then + success "Friendbot funding successful." +elif echo "$FUND_RESPONSE" | grep -qi "createAccountAlreadyExist\|already funded" 2>/dev/null; then + warn "Account already exists — skipping Friendbot (this is fine for redeployments)." +else + warn "Friendbot response unclear. Proceeding anyway — account may already be funded." +fi + +# ── 4. Build all contracts ──────────────────────────────────────────────────── +info "Building all Soroban contracts…" +cd "$CONTRACTS_DIR" +stellar contract build +success "Build complete." + +# ── Locate compiled WASMs ───────────────────────────────────────────────────── +ASSETSUP_WASM=$(find target/wasm32-unknown-unknown/release -name "assetsup*.wasm" | head -1) +MULTISIG_WASM=$(find target/wasm32-unknown-unknown/release -name "multisig_transfer*.wasm" | head -1) + +[[ -f "$ASSETSUP_WASM" ]] || error "assetsup WASM not found after build. Check contracts/assetsup/Cargo.toml." +[[ -f "$MULTISIG_WASM" ]] || error "multisig_transfer WASM not found after build. Check contracts/multisig_transfer/Cargo.toml." + +info "assetsup WASM : $ASSETSUP_WASM" +info "multisig_transfer WASM: $MULTISIG_WASM" + +# ── Helper: deploy one contract ─────────────────────────────────────────────── +deploy_contract() { + local name="$1" + local wasm="$2" + + info "Deploying ${name}…" + local contract_id + contract_id=$(stellar contract deploy \ + --wasm "$wasm" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + 2>&1 | tail -1) + + # Validate output looks like a contract ID (56-char alphanumeric) + if [[ ! "$contract_id" =~ ^[A-Z0-9]{56}$ ]]; then + error "Unexpected output from 'stellar contract deploy' for ${name}:\n ${contract_id}" + fi + + success "${name} deployed: ${contract_id}" + echo "$contract_id" +} + +# ── 5. Deploy assetsup ──────────────────────────────────────────────────────── +ASSETSUP_CONTRACT_ID=$(deploy_contract "assetsup" "$ASSETSUP_WASM") + +# ── 6. Deploy multisig_transfer ─────────────────────────────────────────────── +MULTISIG_CONTRACT_ID=$(deploy_contract "multisig_transfer" "$MULTISIG_WASM") + +# ── 7. Initialise contracts ─────────────────────────────────────────────────── +info "Initialising assetsup contract (admin = ${ADMIN_ADDRESS})…" +stellar contract invoke \ + --id "$ASSETSUP_CONTRACT_ID" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- initialize \ + --admin "$ADMIN_ADDRESS" +success "assetsup initialised." + +info "Initialising multisig_transfer contract (admin = ${ADMIN_ADDRESS})…" +stellar contract invoke \ + --id "$MULTISIG_CONTRACT_ID" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- initialize \ + --admin "$ADMIN_ADDRESS" +success "multisig_transfer initialised." + +# ── 8. Write .env.testnet ───────────────────────────────────────────────────── +info "Writing contract IDs to ${ENV_FILE}…" +cat > "$ENV_FILE" < +# +# contract-name: assetsup | multisig_transfer +# network: testnet | mainnet +# +# Examples: +# ./contracts/scripts/upgrade-contract.sh assetsup testnet +# STELLAR_DEPLOYER_SECRET=S... ./contracts/scripts/upgrade-contract.sh multisig_transfer mainnet +# +# Required environment variables: +# STELLAR_DEPLOYER_SECRET Must be the admin key that can call upgrade() +# +# The contract ID is read from contracts/.env. +# ============================================================================= + +set -euo pipefail + +# ── Colour helpers ──────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${CYAN}[INFO]${RESET} $*"; } +success() { echo -e "${GREEN}[OK]${RESET} $*"; } +warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; exit 1; } + +# ── Usage guard ─────────────────────────────────────────────────────────────── +usage() { + echo "Usage: $0 " + echo " contract-name : assetsup | multisig_transfer" + echo " network : testnet | mainnet" + exit 1 +} + +[[ $# -eq 2 ]] || usage + +CONTRACT_NAME="$1" +NETWORK="$2" +CONTRACTS_DIR="$(dirname "$0")/.." +ENV_FILE="${CONTRACTS_DIR}/.env.${NETWORK}" + +# ── Validate args ───────────────────────────────────────────────────────────── +case "$CONTRACT_NAME" in + assetsup) WASM_GLOB="assetsup*.wasm"; ENV_VAR="ASSETSUP_CONTRACT_ID" ;; + multisig_transfer) WASM_GLOB="multisig_transfer*.wasm"; ENV_VAR="MULTISIG_TRANSFER_CONTRACT_ID" ;; + *) error "Unknown contract '${CONTRACT_NAME}'. Must be: assetsup | multisig_transfer" ;; +esac + +case "$NETWORK" in + testnet) + NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + RPC_URL="https://soroban-testnet.stellar.org" + ;; + mainnet) + NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" + RPC_URL="https://soroban-mainnet.stellar.org" + ;; + *) error "Unknown network '${NETWORK}'. Must be: testnet | mainnet" ;; +esac + +# ── Print header ────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}═══════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD} AssetsUp — Contract Upgrade ${RESET}" +echo -e "${BOLD} Contract : ${CONTRACT_NAME} ${RESET}" +echo -e "${BOLD} Network : ${NETWORK} ${RESET}" +echo -e "${BOLD}═══════════════════════════════════════════════════════${RESET}" +echo "" + +# ── Mainnet confirmation ────────────────────────────────────────────────────── +if [[ "$NETWORK" == "mainnet" ]]; then + echo -e "${YELLOW} ⚠ WARNING: You are upgrading a MAINNET contract.${RESET}" + echo -e "${YELLOW} This will affect all live users.${RESET}" + echo "" + read -rp " Type 'upgrade mainnet ${CONTRACT_NAME}' to confirm: " CONFIRM + if [[ "$CONFIRM" != "upgrade mainnet ${CONTRACT_NAME}" ]]; then + echo "Aborted." + exit 0 + fi + echo "" +fi + +# ── Prerequisites ───────────────────────────────────────────────────────────── +if ! command -v stellar &>/dev/null; then + error "stellar CLI not found. Install with: cargo install --locked stellar-cli --features opt" +fi + +[[ -z "${STELLAR_DEPLOYER_SECRET:-}" ]] && \ + error "STELLAR_DEPLOYER_SECRET must be set. This should be the admin key for the contract." + +[[ -f "$ENV_FILE" ]] || \ + error "Environment file not found: ${ENV_FILE}\n Run deploy-${NETWORK}.sh first." + +# ── Load contract ID from env file ──────────────────────────────────────────── +CONTRACT_ID=$(grep "^${ENV_VAR}=" "$ENV_FILE" | cut -d= -f2) +[[ -z "$CONTRACT_ID" ]] && \ + error "${ENV_VAR} not found in ${ENV_FILE}.\n Re-run the deploy script to populate it." + +info "Contract ID : ${CONTRACT_ID}" +info "Network : ${NETWORK}" +info "RPC URL : ${RPC_URL}" + +# ── Build ───────────────────────────────────────────────────────────────────── +info "Building ${CONTRACT_NAME}…" +cd "$CONTRACTS_DIR" +stellar contract build +success "Build complete." + +WASM_PATH=$(find target/wasm32-unknown-unknown/release -name "$WASM_GLOB" | head -1) +[[ -f "$WASM_PATH" ]] || error "WASM not found after build: ${WASM_GLOB}" +info "WASM path : ${WASM_PATH}" +WASM_SIZE=$(du -sh "$WASM_PATH" | cut -f1) +info "WASM size : ${WASM_SIZE}" + +# ── Upload new WASM to the ledger ───────────────────────────────────────────── +info "Uploading new WASM to ledger…" +NEW_WASM_HASH=$(stellar contract upload \ + --wasm "$WASM_PATH" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + 2>&1 | tail -1) + +[[ -z "$NEW_WASM_HASH" ]] && error "WASM upload returned empty hash." +success "New WASM hash: ${NEW_WASM_HASH}" + +# ── Invoke upgrade() on the deployed contract ───────────────────────────────── +info "Invoking upgrade() on ${CONTRACT_NAME} (${CONTRACT_ID})…" +stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source "$STELLAR_DEPLOYER_SECRET" \ + --network "$NETWORK" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- upgrade \ + --new_wasm_hash "$NEW_WASM_HASH" + +success "Upgrade transaction submitted." + +# ── Update env file with new WASM hash (informational) ─────────────────────── +TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Append/update WASM hash comment in env file +if grep -q "^${ENV_VAR}_WASM_HASH=" "$ENV_FILE" 2>/dev/null; then + # Replace existing line + sed -i.bak "s|^${ENV_VAR}_WASM_HASH=.*|${ENV_VAR}_WASM_HASH=${NEW_WASM_HASH} # upgraded ${TIMESTAMP}|" "$ENV_FILE" + rm -f "${ENV_FILE}.bak" +else + echo "" >> "$ENV_FILE" + echo "# Last upgrade" >> "$ENV_FILE" + echo "${ENV_VAR}_WASM_HASH=${NEW_WASM_HASH} # upgraded ${TIMESTAMP}" >> "$ENV_FILE" +fi + +success "Updated ${ENV_FILE} with new WASM hash." + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}═══════════════════════════════════════════════════════${RESET}" +echo -e "${BOLD} Upgrade Complete ✓ ${RESET}" +echo -e "${BOLD}═══════════════════════════════════════════════════════${RESET}" +echo "" +echo -e " ${GREEN}Contract :${RESET} ${CONTRACT_NAME}" +echo -e " ${GREEN}Contract ID :${RESET} ${CONTRACT_ID}" +echo -e " ${GREEN}New WASM hash :${RESET} ${NEW_WASM_HASH}" +echo -e " ${GREEN}Network :${RESET} ${NETWORK}" +echo "" +case "$NETWORK" in + testnet) EXPLORER="https://stellar.expert/explorer/testnet/contract/${CONTRACT_ID}" ;; + mainnet) EXPLORER="https://stellar.expert/explorer/public/contract/${CONTRACT_ID}" ;; +esac +echo -e " ${CYAN}Explorer: ${EXPLORER}${RESET}" +echo "" \ No newline at end of file diff --git a/contracts/types/assetsup.ts b/contracts/types/assetsup.ts new file mode 100644 index 000000000..4adf56d48 --- /dev/null +++ b/contracts/types/assetsup.ts @@ -0,0 +1,422 @@ +/** + * contracts/types/assetsup.ts + * + * TypeScript bindings for the `assetsup` Soroban contract. + * + * Auto-generated by: + * stellar contract bindings typescript \ + * --wasm target/wasm32-unknown-unknown/release/assetsup.wasm \ + * --network testnet \ + * --output-dir contracts/types + * + * Then manually annotated for clarity. Re-run the above command after any + * contract change to keep this file in sync with the on-chain ABI. + * + * Soroban XDR <-> TypeScript mapping used throughout: + * Address -> string (G... Stellar public key or C... contract ID) + * Bytes -> Buffer | Uint8Array + * BytesN -> Buffer (fixed-length N bytes) + * String -> string + * Symbol -> string (≤ 32 chars, used as enum discriminant) + * Map -> Map + * Vec -> T[] + * bool -> boolean + * u32 -> number + * i32 -> number + * u64 -> bigint + * i64 -> bigint + * u128 -> bigint + * i128 -> bigint + * u256 -> bigint + * i256 -> bigint + * Option -> T | null + */ + +import { + Contract, + SorobanRpc, + TransactionBuilder, + Networks, + BASE_FEE, + nativeToScVal, + scValToNative, + Address, + xdr, + } from "@stellar/stellar-sdk"; + + // ─── Enums ──────────────────────────────────────────────────────────────────── + + /** + * Asset condition — mirrors the `AssetCondition` Soroban enum. + * Stored as a Symbol XDR value on-chain. + */ + export enum AssetCondition { + New = "New", + Good = "Good", + Fair = "Fair", + Poor = "Poor", + Decommissioned = "Decommissioned", + } + + /** + * Asset status — mirrors the `AssetStatus` Soroban enum. + */ + export enum AssetStatus { + Active = "Active", + Inactive = "Inactive", + UnderMaintenance = "UnderMaintenance", + Disposed = "Disposed", + Lost = "Lost", + } + + // ─── Structs ────────────────────────────────────────────────────────────────── + + /** + * Core asset record stored in contract storage. + * Mirrors the `Asset` struct in `contracts/assetsup/src/types.rs`. + */ + export interface Asset { + /** Unique asset identifier (UUID or internal ID). Soroban type: String */ + id: string; + /** Human-readable asset name. Soroban type: String */ + name: string; + /** Asset category (e.g. "IT Equipment", "Furniture"). Soroban type: String */ + category: string; + /** Manufacturer serial number. Soroban type: String */ + serial_number: string; + /** Optional free-text description. Soroban type: Option */ + description: string | null; + /** ID of the department that owns this asset. Soroban type: String */ + department_id: string; + /** ID of the physical location. Soroban type: String */ + location_id: string; + /** Stellar address of the assigned user. Soroban type: Address */ + assigned_user_id: string; + /** ISO-8601 date string of purchase. Soroban type: String */ + purchase_date: string; + /** Purchase value in stroops (1 XLM = 10_000_000 stroops). Soroban type: i128 */ + purchase_value: bigint; + /** Current physical condition. Soroban type: Symbol (AssetCondition) */ + condition: AssetCondition; + /** Lifecycle status. Soroban type: Symbol (AssetStatus) */ + status: AssetStatus; + /** Ledger timestamp of last update. Soroban type: u64 */ + updated_at: bigint; + /** Stellar address of the admin that created this record. Soroban type: Address */ + created_by: string; + } + + /** + * Payload for creating a new asset. + * All fields are required except `description`. + */ + export interface CreateAssetPayload { + name: string; + category: string; + serial_number: string; + description?: string | null; + department_id: string; + location_id: string; + assigned_user_id: string; + purchase_date: string; + /** In stroops. Use `BigInt(Math.round(xlmAmount * 1e7))` to convert from XLM. */ + purchase_value: bigint; + condition: AssetCondition; + status: AssetStatus; + } + + /** + * Payload for updating an existing asset. + * All fields are optional — only provided fields are updated. + * Mirrors `EditAssetPayload` in the frontend. + */ + export interface UpdateAssetPayload { + name?: string; + category?: string; + serial_number?: string; + description?: string | null; + department_id?: string; + location_id?: string; + assigned_user_id?: string; + purchase_date?: string; + purchase_value?: bigint; + condition?: AssetCondition; + status?: AssetStatus; + } + + /** + * Paginated list result returned by `list_assets`. + */ + export interface AssetPage { + /** Slice of assets for the requested page. */ + items: Asset[]; + /** Total number of assets in storage (for pagination UI). Soroban type: u32 */ + total: number; + } + + // ─── Error codes ────────────────────────────────────────────────────────────── + + /** + * Contract error codes returned as XDR error values. + * Map these to user-facing messages in the backend. + */ + export enum AssetsUpError { + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + AssetNotFound = 4, + AssetAlreadyExists = 5, + InvalidInput = 6, + StorageError = 7, + } + + // ─── Client class ───────────────────────────────────────────────────────────── + + export interface AssetsUpClientOptions { + /** Deployed contract ID (C... address). */ + contractId: string; + /** RPC server URL. */ + rpcUrl: string; + /** + * Network passphrase. + * Use `Networks.TESTNET` or `Networks.PUBLIC` from @stellar/stellar-sdk. + */ + networkPassphrase: string; + } + + /** + * Type-safe client for the `assetsup` Soroban contract. + * + * @example + * ```ts + * import { AssetsUpClient, AssetCondition, AssetStatus } from "./types/assetsup"; + * import { Networks } from "@stellar/stellar-sdk"; + * + * const client = new AssetsUpClient({ + * contractId: process.env.ASSETSUP_CONTRACT_ID!, + * rpcUrl: "https://soroban-testnet.stellar.org", + * networkPassphrase: Networks.TESTNET, + * }); + * + * const asset = await client.getAsset("asset-001"); + * ``` + */ + export class AssetsUpClient { + private readonly contract: Contract; + private readonly server: SorobanRpc.Server; + private readonly networkPassphrase: string; + + constructor(opts: AssetsUpClientOptions) { + this.contract = new Contract(opts.contractId); + this.server = new SorobanRpc.Server(opts.rpcUrl); + this.networkPassphrase = opts.networkPassphrase; + } + + // ── Read-only calls (simulation only) ────────────────────────────────────── + + /** + * Fetch a single asset by ID. + * + * Soroban function: `get_asset(id: String) -> Asset` + * Access: Public (anyone can call) + * + * @throws {AssetsUpError.AssetNotFound} if the ID does not exist + */ + async getAsset(id: string): Promise { + const operation = this.contract.call( + "get_asset", + nativeToScVal(id, { type: "string" }) + ); + const result = await this.simulateOperation(operation); + return scValToNative(result) as Asset; + } + + /** + * List assets with optional pagination. + * + * Soroban function: `list_assets(page: u32, page_size: u32) -> AssetPage` + * Access: Public + * + * @param page Zero-based page index (default 0) + * @param pageSize Number of records per page, max 100 (default 20) + */ + async listAssets(page = 0, pageSize = 20): Promise { + const operation = this.contract.call( + "list_assets", + nativeToScVal(page, { type: "u32" }), + nativeToScVal(pageSize, { type: "u32" }) + ); + const result = await this.simulateOperation(operation); + return scValToNative(result) as AssetPage; + } + + /** + * Query assets assigned to a specific user. + * + * Soroban function: `get_assets_by_user(user: Address) -> Vec` + * Access: Public + */ + async getAssetsByUser(userAddress: string): Promise { + const operation = this.contract.call( + "get_assets_by_user", + new Address(userAddress).toScVal() + ); + const result = await this.simulateOperation(operation); + return scValToNative(result) as Asset[]; + } + + /** + * Query assets in a given department. + * + * Soroban function: `get_assets_by_department(department_id: String) -> Vec` + * Access: Public + */ + async getAssetsByDepartment(departmentId: string): Promise { + const operation = this.contract.call( + "get_assets_by_department", + nativeToScVal(departmentId, { type: "string" }) + ); + const result = await this.simulateOperation(operation); + return scValToNative(result) as Asset[]; + } + + /** + * Return the current admin address. + * + * Soroban function: `get_admin() -> Address` + * Access: Public + */ + async getAdmin(): Promise { + const operation = this.contract.call("get_admin"); + const result = await this.simulateOperation(operation); + return scValToNative(result) as string; + } + + // ── Write calls (require signing) ────────────────────────────────────────── + + /** + * Build a `create_asset` transaction for signing. + * + * Soroban function: `create_asset(caller: Address, payload: CreateAssetPayload) -> String` + * Returns: the newly created asset ID + * Access: Admin only + * + * @example + * ```ts + * const txXdr = await client.buildCreateAsset(callerKeypair.publicKey(), payload); + * // Sign and submit with your Keypair or wallet + * const signedTx = txXdr.sign(callerKeypair); + * await server.sendTransaction(signedTx); + * ``` + */ + async buildCreateAsset( + callerAddress: string, + payload: CreateAssetPayload, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const operation = this.contract.call( + "create_asset", + new Address(callerAddress).toScVal(), + nativeToScVal(payload) + ); + return this.buildTransaction(operation, sourceAccount); + } + + /** + * Build an `update_asset` transaction for signing. + * + * Soroban function: `update_asset(caller: Address, id: String, payload: UpdateAssetPayload) -> Asset` + * Returns: the updated Asset record + * Access: Admin only + */ + async buildUpdateAsset( + callerAddress: string, + assetId: string, + payload: UpdateAssetPayload, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const operation = this.contract.call( + "update_asset", + new Address(callerAddress).toScVal(), + nativeToScVal(assetId, { type: "string" }), + nativeToScVal(payload) + ); + return this.buildTransaction(operation, sourceAccount); + } + + /** + * Build a `delete_asset` transaction for signing. + * + * Soroban function: `delete_asset(caller: Address, id: String) -> bool` + * Returns: true on success + * Access: Admin only + */ + async buildDeleteAsset( + callerAddress: string, + assetId: string, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const operation = this.contract.call( + "delete_asset", + new Address(callerAddress).toScVal(), + nativeToScVal(assetId, { type: "string" }) + ); + return this.buildTransaction(operation, sourceAccount); + } + + /** + * Build an `upgrade` transaction (admin-only, used by upgrade-contract.sh). + * + * Soroban function: `upgrade(new_wasm_hash: BytesN<32>) -> ()` + * Access: Admin only + */ + async buildUpgrade( + newWasmHash: Buffer, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const operation = this.contract.call( + "upgrade", + xdr.ScVal.scvBytes(newWasmHash) + ); + return this.buildTransaction(operation, sourceAccount); + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async simulateOperation( + operation: xdr.Operation + ): Promise { + const account = await this.server.getAccount( + // Use a burn address for read-only simulations + "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + ); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30) + .build(); + + const simResult = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + if (!simResult.result) { + throw new Error("Simulation returned no result."); + } + return simResult.result.retval; + } + + private async buildTransaction( + operation: xdr.Operation, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(operation) + .setTimeout(30); + } + } \ No newline at end of file diff --git a/contracts/types/multisig_transfer.ts b/contracts/types/multisig_transfer.ts new file mode 100644 index 000000000..8351a4799 --- /dev/null +++ b/contracts/types/multisig_transfer.ts @@ -0,0 +1,431 @@ +/** + * contracts/types/multisig_transfer.ts + * + * TypeScript bindings for the `multisig_transfer` Soroban contract. + * + * Auto-generated by: + * stellar contract bindings typescript \ + * --wasm target/wasm32-unknown-unknown/release/multisig_transfer.wasm \ + * --network testnet \ + * --output-dir contracts/types + * + * Then manually annotated for clarity. Re-run the above command after any + * contract change to keep this file in sync with the on-chain ABI. + * + * XDR <-> TypeScript mapping: see assetsup.ts header for the full table. + */ + +import { + Contract, + SorobanRpc, + TransactionBuilder, + Networks, + BASE_FEE, + nativeToScVal, + scValToNative, + Address, + xdr, + } from "@stellar/stellar-sdk"; + + // ─── Enums ──────────────────────────────────────────────────────────────────── + + /** + * Lifecycle state of a transfer proposal. + * Mirrors `TransferStatus` in `contracts/multisig_transfer/src/types.rs`. + */ + export enum TransferStatus { + /** Proposal created, awaiting signatures. */ + Pending = "Pending", + /** Enough signers have approved — transfer can be executed. */ + Approved = "Approved", + /** Transfer has been executed on-chain. */ + Executed = "Executed", + /** Proposal was rejected (threshold of rejections met, or admin cancelled). */ + Rejected = "Rejected", + /** Proposal expired without reaching the signature threshold. */ + Expired = "Expired", + } + + /** + * A signer's decision on a transfer proposal. + */ + export enum SignerDecision { + Approve = "Approve", + Reject = "Reject", + } + + // ─── Structs ────────────────────────────────────────────────────────────────── + + /** + * A single signer entry within a multisig transfer proposal. + */ + export interface SignerEntry { + /** Stellar address of the signer. Soroban type: Address */ + address: string; + /** Whether they have signed. Soroban type: bool */ + has_signed: boolean; + /** Their decision, if signed. Soroban type: Option */ + decision: SignerDecision | null; + /** Ledger timestamp of signature. Soroban type: Option */ + signed_at: bigint | null; + } + + /** + * A multi-signature asset transfer proposal. + * Mirrors `TransferProposal` in `contracts/multisig_transfer/src/types.rs`. + */ + export interface TransferProposal { + /** Unique proposal ID. Soroban type: String */ + id: string; + /** The asset being transferred. Soroban type: String */ + asset_id: string; + /** Current holder of the asset. Soroban type: Address */ + from_user: string; + /** Intended new holder of the asset. Soroban type: Address */ + to_user: string; + /** Optional transfer notes / reason. Soroban type: Option */ + notes: string | null; + /** Minimum number of approvals required. Soroban type: u32 */ + required_signatures: number; + /** All signers and their current state. Soroban type: Vec */ + signers: SignerEntry[]; + /** Current proposal status. Soroban type: Symbol (TransferStatus) */ + status: TransferStatus; + /** Ledger timestamp when proposal was created. Soroban type: u64 */ + created_at: bigint; + /** Ledger timestamp after which the proposal expires. Soroban type: u64 */ + expires_at: bigint; + /** Ledger timestamp when executed (if applicable). Soroban type: Option */ + executed_at: bigint | null; + /** Address that created this proposal. Soroban type: Address */ + proposed_by: string; + } + + /** + * Payload to create a new transfer proposal. + */ + export interface CreateTransferPayload { + /** ID of the asset to transfer (must exist in the assetsup contract). */ + asset_id: string; + /** Address of the current asset holder. */ + from_user: string; + /** Address of the intended recipient. */ + to_user: string; + /** Optional description/reason for the transfer. */ + notes?: string | null; + /** Number of approvals needed (must be ≤ signers.length). */ + required_signatures: number; + /** Addresses of signers who must approve. */ + signers: string[]; + /** + * Ledger timestamp (seconds since Unix epoch) when proposal expires. + * Use `Date.now() / 1000 + 86400` for a 24-hour expiry. + */ + expires_at: bigint; + } + + /** + * Summary counters returned by `get_proposal_stats`. + */ + export interface ProposalStats { + /** Total proposals ever created. Soroban type: u32 */ + total: number; + /** Currently pending proposals. Soroban type: u32 */ + pending: number; + /** Proposals in Approved state (not yet executed). Soroban type: u32 */ + approved: number; + /** Proposals successfully executed. Soroban type: u32 */ + executed: number; + /** Proposals that were rejected or expired. Soroban type: u32 */ + rejected: number; + } + + // ─── Error codes ────────────────────────────────────────────────────────────── + + export enum MultisigTransferError { + NotInitialized = 1, + AlreadyInitialized = 2, + Unauthorized = 3, + ProposalNotFound = 4, + ProposalAlreadyExists = 5, + ProposalNotPending = 6, + SignerNotFound = 7, + AlreadySigned = 8, + InsufficientSignatures = 9, + ProposalExpired = 10, + InvalidInput = 11, + } + + // ─── Client class ───────────────────────────────────────────────────────────── + + export interface MultisigTransferClientOptions { + contractId: string; + rpcUrl: string; + networkPassphrase: string; + } + + /** + * Type-safe client for the `multisig_transfer` Soroban contract. + * + * @example + * ```ts + * import { MultisigTransferClient, TransferStatus } from "./types/multisig_transfer"; + * import { Networks } from "@stellar/stellar-sdk"; + * + * const client = new MultisigTransferClient({ + * contractId: process.env.MULTISIG_TRANSFER_CONTRACT_ID!, + * rpcUrl: "https://soroban-testnet.stellar.org", + * networkPassphrase: Networks.TESTNET, + * }); + * + * const proposals = await client.listProposalsByAsset("asset-001"); + * ``` + */ + export class MultisigTransferClient { + private readonly contract: Contract; + private readonly server: SorobanRpc.Server; + private readonly networkPassphrase: string; + + constructor(opts: MultisigTransferClientOptions) { + this.contract = new Contract(opts.contractId); + this.server = new SorobanRpc.Server(opts.rpcUrl); + this.networkPassphrase = opts.networkPassphrase; + } + + // ── Read-only calls ──────────────────────────────────────────────────────── + + /** + * Fetch a single transfer proposal by ID. + * + * Soroban function: `get_proposal(id: String) -> TransferProposal` + * Access: Public + * + * @throws {MultisigTransferError.ProposalNotFound} + */ + async getProposal(id: string): Promise { + const op = this.contract.call( + "get_proposal", + nativeToScVal(id, { type: "string" }) + ); + const result = await this.simulateOperation(op); + return scValToNative(result) as TransferProposal; + } + + /** + * List all proposals for a given asset. + * + * Soroban function: `list_proposals_by_asset(asset_id: String) -> Vec` + * Access: Public + */ + async listProposalsByAsset(assetId: string): Promise { + const op = this.contract.call( + "list_proposals_by_asset", + nativeToScVal(assetId, { type: "string" }) + ); + const result = await this.simulateOperation(op); + return scValToNative(result) as TransferProposal[]; + } + + /** + * List all proposals where the given user is a required signer. + * + * Soroban function: `list_proposals_for_signer(signer: Address) -> Vec` + * Access: Public + */ + async listProposalsForSigner(signerAddress: string): Promise { + const op = this.contract.call( + "list_proposals_for_signer", + new Address(signerAddress).toScVal() + ); + const result = await this.simulateOperation(op); + return scValToNative(result) as TransferProposal[]; + } + + /** + * Get aggregate proposal statistics. + * + * Soroban function: `get_proposal_stats() -> ProposalStats` + * Access: Public + */ + async getProposalStats(): Promise { + const op = this.contract.call("get_proposal_stats"); + const result = await this.simulateOperation(op); + return scValToNative(result) as ProposalStats; + } + + /** + * Check whether a specific signer has signed a proposal. + * + * Soroban function: `has_signed(proposal_id: String, signer: Address) -> bool` + * Access: Public + */ + async hasSigned(proposalId: string, signerAddress: string): Promise { + const op = this.contract.call( + "has_signed", + nativeToScVal(proposalId, { type: "string" }), + new Address(signerAddress).toScVal() + ); + const result = await this.simulateOperation(op); + return scValToNative(result) as boolean; + } + + // ── Write calls (require signing) ────────────────────────────────────────── + + /** + * Build a `create_proposal` transaction for signing. + * + * Soroban function: `create_proposal(caller: Address, payload: CreateTransferPayload) -> String` + * Returns: the newly created proposal ID + * Access: Admin or authorised user + * + * @example + * ```ts + * const txBuilder = await client.buildCreateProposal( + * keypair.publicKey(), + * { + * asset_id: "asset-001", + * from_user: "GABC...", + * to_user: "GXYZ...", + * required_signatures: 2, + * signers: ["GABC...", "GDEF...", "GHIJ..."], + * expires_at: BigInt(Math.floor(Date.now() / 1000) + 86400), + * }, + * sourceAccount + * ); + * const tx = txBuilder.build(); + * tx.sign(keypair); + * await server.sendTransaction(tx); + * ``` + */ + async buildCreateProposal( + callerAddress: string, + payload: CreateTransferPayload, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const op = this.contract.call( + "create_proposal", + new Address(callerAddress).toScVal(), + nativeToScVal(payload) + ); + return this.buildTransaction(op, sourceAccount); + } + + /** + * Build a `sign_proposal` transaction for signing. + * + * Soroban function: `sign_proposal(signer: Address, proposal_id: String, decision: Symbol) -> TransferProposal` + * Returns: updated TransferProposal + * Access: Must be one of the listed signers on the proposal + * + * @throws {MultisigTransferError.SignerNotFound} + * @throws {MultisigTransferError.AlreadySigned} + * @throws {MultisigTransferError.ProposalExpired} + */ + async buildSignProposal( + signerAddress: string, + proposalId: string, + decision: SignerDecision, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const op = this.contract.call( + "sign_proposal", + new Address(signerAddress).toScVal(), + nativeToScVal(proposalId, { type: "string" }), + nativeToScVal(decision, { type: "symbol" }) + ); + return this.buildTransaction(op, sourceAccount); + } + + /** + * Build an `execute_transfer` transaction for signing. + * + * Soroban function: `execute_transfer(caller: Address, proposal_id: String) -> TransferProposal` + * Returns: final executed TransferProposal + * Access: Admin or the proposal initiator, only when status is Approved + * + * @throws {MultisigTransferError.InsufficientSignatures} + * @throws {MultisigTransferError.ProposalNotPending} + */ + async buildExecuteTransfer( + callerAddress: string, + proposalId: string, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const op = this.contract.call( + "execute_transfer", + new Address(callerAddress).toScVal(), + nativeToScVal(proposalId, { type: "string" }) + ); + return this.buildTransaction(op, sourceAccount); + } + + /** + * Build a `cancel_proposal` transaction for signing. + * + * Soroban function: `cancel_proposal(caller: Address, proposal_id: String) -> bool` + * Returns: true on success + * Access: Admin only + */ + async buildCancelProposal( + callerAddress: string, + proposalId: string, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const op = this.contract.call( + "cancel_proposal", + new Address(callerAddress).toScVal(), + nativeToScVal(proposalId, { type: "string" }) + ); + return this.buildTransaction(op, sourceAccount); + } + + /** + * Build an `upgrade` transaction (admin-only). + * + * Soroban function: `upgrade(new_wasm_hash: BytesN<32>) -> ()` + * Access: Admin only + */ + async buildUpgrade( + newWasmHash: Buffer, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + const op = this.contract.call("upgrade", xdr.ScVal.scvBytes(newWasmHash)); + return this.buildTransaction(op, sourceAccount); + } + + // ── Private helpers ───────────────────────────────────────────────────────── + + private async simulateOperation(op: xdr.Operation): Promise { + const account = await this.server.getAccount( + "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + ); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(op) + .setTimeout(30) + .build(); + + const simResult = await this.server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + if (!simResult.result) { + throw new Error("Simulation returned no result."); + } + return simResult.result.retval; + } + + private async buildTransaction( + op: xdr.Operation, + sourceAccount: SorobanRpc.Api.GetAccountResponse + ): Promise { + return new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(op) + .setTimeout(30); + } + } \ No newline at end of file