Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
a623f16
feat: handle Summon wallets with capability-based metadata
kanyuku Mar 3, 2026
b1c8edc
fix(wallet): address PR feedback on legacy Summon wallet CBOR preserv…
kanyuku Mar 28, 2026
a02ad79
feat: agent onboarding, governance overview, wallet transfer, ballot UX
QSchlegel May 11, 2026
e2d21b7
docs(roadmap): MRP task mapping, April–July delivery window, drop tea…
QSchlegel Aug 7, 2026
a226429
feat(documents): Document Sign-Off MVP — data model, routes, CIP-8 si…
QSchlegel Aug 7, 2026
cea05c6
feat(proxy): manage proxy access in-app and explain the on-chain flow
QSchlegel Aug 13, 2026
1703499
docs(mcp): setup instructions that work for GUI clients
QSchlegel Aug 14, 2026
70160f6
Merge pull request #370 from MeshJS/claude/mcp-gui-setup
QSchlegel Aug 14, 2026
12650c4
Merge pull request #364 from MeshJS/claude/proxy-user-management-ux-6…
QSchlegel Aug 14, 2026
5666995
feat: update roadmap for September and October 2026 with new focus ar…
Andre-Diamond Aug 18, 2026
fbb9b9c
Merge origin/preprod into claude/document-signoff-mvp
QSchlegel Aug 19, 2026
36f6ec5
feat(seo): a social card per route, not one card for the whole site
QSchlegel Aug 19, 2026
f745042
Merge pull request #356 from MeshJS/claude/document-signoff-mvp
QSchlegel Aug 21, 2026
afc2eb0
Merge branch 'preprod' into claude/happy-bhabha-fa7fd2
QSchlegel Aug 21, 2026
157340a
Merge preprod into feature/issue-204-summon-api-routes + address review
QSchlegel Aug 21, 2026
ec26d70
Merge pull request #254 from MeshJS/claude/happy-bhabha-fa7fd2
QSchlegel Aug 21, 2026
4499525
Merge preprod (now including #254) into the Summon capability branch
QSchlegel Aug 21, 2026
7209692
Merge pull request #371 from MeshJS/contrib/issue-204-summon-capabili…
QSchlegel Aug 21, 2026
371688b
feat: shielded sign-off — hash-linked vault trust graph and selective…
Aug 21, 2026
4c7071e
feat: vault browser with the trust graph as an overlay
Aug 21, 2026
90354eb
feat(vault): interactive knowledge graph, rendered notes, public route
QSchlegel Aug 21, 2026
43383ae
feat(seo): give /vault its own metadata, social card and sitemap entry
QSchlegel Aug 21, 2026
f5f7074
feat(vault): link /vault from the footer and the crawler fallback
QSchlegel Aug 21, 2026
5254a39
fix: Document Sign-Off shipped with no way to reach it
Aug 21, 2026
7c437db
fix(signing): sign() rejected every valid signature
QSchlegel Aug 21, 2026
6348995
fix(vault): bind document identity into the commitment, and 6 other d…
QSchlegel Aug 21, 2026
d694ca2
feat(documents): platform attestation — signed, chained version history
QSchlegel Aug 21, 2026
6c0ca2f
feat(mcp): read-only document tools, on a new v1 REST surface
QSchlegel Aug 21, 2026
2757278
Merge pull request #372 from MeshJS/claude/fix-signdata-verification
QSchlegel Aug 21, 2026
67d0afb
Merge pull request #373 from MeshJS/claude/documents-nav-entry
QSchlegel Aug 21, 2026
937d5e3
Merge pull request #374 from MeshJS/claude/vault-view
QSchlegel Aug 21, 2026
02ac01a
Merge pull request #375 from MeshJS/claude/document-attestation
QSchlegel Aug 21, 2026
32c19c7
Merge pull request #376 from MeshJS/claude/mcp-documents
QSchlegel Aug 21, 2026
1293046
feat(documents): preview shielded sign-off inside the Documents section
QSchlegel Aug 21, 2026
8e4b040
Merge pull request #377 from MeshJS/claude/documents-vault-preview
QSchlegel Aug 22, 2026
18c7dda
fix(security): unaccepted invitations are a count, not attacker-chose…
QSchlegel Aug 22, 2026
3278535
feat(documents): DocumentDraft — a mutable body that cannot be signed
QSchlegel Aug 22, 2026
4334bfb
Merge pull request #379 from MeshJS/claude/wallet-invite-count
QSchlegel Aug 22, 2026
c339150
Merge pull request #380 from MeshJS/claude/document-draft-layer
QSchlegel Aug 22, 2026
ad2f119
feat(documents): a wallet's own vault, built from the database
QSchlegel Aug 22, 2026
0da6532
Merge pull request #381 from MeshJS/claude/wallet-vault-view
QSchlegel Aug 22, 2026
17d61e0
feat(documents): real upload control, and archive / delete actions
QSchlegel Aug 22, 2026
08f595f
Merge pull request #378 from MeshJS/claude/documents-detail-actions
QSchlegel Aug 22, 2026
7291ccf
feat(documents): draft editor with live preview and honest collaboration
QSchlegel Aug 22, 2026
654c018
Merge pull request #382 from MeshJS/claude/document-editor
QSchlegel Aug 22, 2026
1911d5e
docs(vault): a how-to for shielded sign-off, linked from both vaults
QSchlegel Aug 23, 2026
3cae58e
fix(security): RLS for DocumentDraft and DocumentAttestation
QSchlegel Aug 23, 2026
c1a103a
test(documents): end-to-end sign-off against a real database and real…
QSchlegel Aug 23, 2026
a5e2464
Merge pull request #383 from MeshJS/claude/shielded-signoff-guide
QSchlegel Aug 23, 2026
429b231
Merge pull request #384 from MeshJS/claude/rls-document-tables
QSchlegel Aug 23, 2026
2dead16
Merge pull request #386 from MeshJS/claude/document-e2e
QSchlegel Aug 23, 2026
aa45c22
feat(documents): contract parties, roles, optional parties and dual r…
QSchlegel Aug 23, 2026
5739ddd
Merge pull request #387 from MeshJS/claude/contract-model-v2
QSchlegel Aug 23, 2026
5402943
feat(documents): let a named contract party reach the contract
QSchlegel Aug 23, 2026
5207810
Merge pull request #388 from MeshJS/claude/contract-access
QSchlegel Aug 23, 2026
b6f5e17
feat(roadmap): add project task board with multisig payouts to roadmap
Andre-Diamond Aug 24, 2026
80b9656
feat: enhance transaction building with metadata support and change h…
Andre-Diamond Aug 26, 2026
3958b1f
feat(tx-draft): introduce funding source management for transaction d…
Andre-Diamond Aug 27, 2026
7f4bc1f
feat(notifications): implement ballot deadline reminders and threshol…
Andre-Diamond Aug 27, 2026
e57681c
feat: add participantsInclude function for key hash matching in regis…
Andre-Diamond Aug 31, 2026
212e0ac
Merge pull request #391 from MeshJS/feat/sign-off-tx-visualization
Andre-Diamond Aug 31, 2026
f372383
fix(vault): escape square brackets correctly in markdown links
Andre-Diamond Aug 31, 2026
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
50 changes: 50 additions & 0 deletions .github/workflows/ballot-deadline-reminders.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Ballot Deadline Reminders

# Enqueues 48h/24h ballot-deadline reminder emails (derived from on-chain
# proposal expiration epochs) and drains the outbox, by calling the
# authenticated reminder endpoint. Shares NOTIFICATION_DRAIN_SECRET with the
# outbox drain workflow; until it is set both here and on the deployment, runs
# are graceful no-ops.

on:
schedule:
# Hourly; reminder windows are 24h wide so this is ample resolution.
- cron: '17 * * * *'
# Allow manual triggering for testing
workflow_dispatch:

jobs:
remind:
runs-on: ubuntu-latest
timeout-minutes: 5

steps:
- name: Scan ballots and enqueue deadline reminders
env:
API_BASE_URL: 'https://multisig.meshjs.dev'
DRAIN_SECRET: ${{ secrets.NOTIFICATION_DRAIN_SECRET }}
run: |
if [ -z "$DRAIN_SECRET" ]; then
echo "NOTIFICATION_DRAIN_SECRET repo secret is not set; skipping."
exit 0
fi

status=$(curl -s -o response.json -w "%{http_code}" -X POST \
"$API_BASE_URL/api/notifications/ballot-deadlines" \
-H "Authorization: Bearer $DRAIN_SECRET")

echo "HTTP $status"
cat response.json || true
echo

case "$status" in
200)
;;
503)
echo "Endpoint reports NOTIFICATION_DRAIN_SECRET is not configured on the deployment; skipping."
;;
*)
echo "Ballot deadline reminder request failed."
exit 1
;;
esac
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,8 @@ The application provides comprehensive API documentation through Swagger UI:
- `GET /api/v1/walletIds` - Get user's wallet IDs
- `POST /api/v1/addTransaction` - Create new transaction
- `POST /api/v1/authSigner` - Authenticate signer
- `GET /api/v1/lookupMultisigWallet` - Lookup multisig wallet
- `GET /api/v1/lookupMultisigWallet` - Lookup multisig wallet registrations by signer key hash
- `GET /api/v1/resolveScript` - Resolve a native script (policy id or wallet address) to its signer key hashes
- `POST /api/discord/send-message` - Send Discord notifications

> 💡 **Tip**: The Swagger UI provides interactive API testing. Start the dev server and visit `/api-docs` to explore all available endpoints.
Expand Down
93 changes: 70 additions & 23 deletions ROADMAP.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/notification-center-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,15 @@ Manual QA:
7. Enable production for verified internal/test signers.
8. Remove or migrate client-side Discord reminder calls after email path is stable.

## Phase 12: Threshold-Reached and Ballot-Deadline Events (shipped 2026-08-27)

Two more toggles on `WalletSignerNotificationSetting` (`notifyThresholdReached`, `notifyBallotDeadlines`) and two event types on the same outbox:

- `threshold.reached` — enqueued by `enqueueThresholdReachedNotifications` (`src/lib/notifications/center.ts`) from `transaction.updateTransaction`, `signable.updateSignable` and `POST /api/v1/signTransaction` whenever a signature update moves a resource from below to at-or-above `getRequiredSignerCount`. Audience is every wallet signer except the actor (`resolveWalletSignerRecipients`), so unlike `signature.required` the creator and earlier signers are included. One row per resource × recipient.
- `ballot.deadline` — `enqueueBallotDeadlineReminders` (`src/lib/notifications/ballotDeadlines.ts`) has two sources: saved ballots, and pending (`state: 0`) transactions that vote (proposal ids read from `txJson.votes[].vote.govActionId` and `txJson.proxyBot.votes[].proposalId` via `extractVoteProposalIds`), so a direct vote cast without a ballot is covered too. The deadline is the earliest active proposal's Blockfrost `expiration` epoch (end of that epoch = `end_time(latest) + Δepochs × 432000s`); exactly one window per run (`48h` = 24–48h out, `24h` = 0–24h out); rows are keyed on `<ballot|transaction>` × id × signer × window × expiration epoch. Transaction reminders stop by themselves once the tx is submitted. A ballot whose expiring proposals are all covered by a pending vote tx of the same wallet defers to that transaction's reminder (one email, not two); ballots with a submitted `Ballot Vote:`/`Proxy Ballot Vote:` transaction created after the ballot are skipped. Client-side proxy votes (the vote lives in a Plutus redeemer) are covered because `useTransaction.newTransaction` accepts `txJsonExtras`, and the proxy-vote call sites (`proposal/voteButtton.tsx`, `ballot/ballot.tsx`) annotate the stored txJson with the same `proxyBot: { kind: "proxyVote", votes }` block the bot API writes — a client-written annotation, only present on transactions created after 2026-08-27. Driven hourly by `.github/workflows/ballot-deadline-reminders.yml` → `POST /api/notifications/ballot-deadlines` (same `NOTIFICATION_DRAIN_SECRET`).

The worker's send-time preference re-check is keyed by `getNotificationPreferenceField(eventType, resourceType)` (`events.ts`), so both new events honour toggles flipped after enqueue.

## Open Questions

- Should wallet creators be allowed to enter another signer's email, or should emails only be entered and verified by the signer themselves?
Expand Down
276 changes: 276 additions & 0 deletions e2e/tests/discover-wallet-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
// Discover tab (import wizard, "Discover on-chain"): lookup by signer / policy.
//
// The chain reads behind the tab are intercepted in the browser so the spec is
// deterministic and needs no on-chain registration:
// - /api/v1/lookupMultisigWallet -> one registration whose participants ECHO
// the requested pubKeyHashes. The default
// view therefore lists a wallet the signer
// belongs to (Import enabled), while a
// search for someone else's keys yields a
// wallet they are not part of (view-only).
// - /api/v1/resolveScript -> a fixed pair of fake signer hashes, so a
// policy search resolves to a wallet the
// signer is not part of; returns no signers
// for the bare-hash fallback case.
//
// Import itself (resolveRegistrationScript + script reconstruction) is covered
// by unit tests; nothing here is ever persisted.

import { test, expect } from "../fixtures/authFixture";
import { loadContext } from "../helpers/contextLoader";
import type { Page } from "@playwright/test";

const REGISTRATION_TX = "4".repeat(64);
const FOREIGN_SIG_HASHES = ["5".repeat(56), "6".repeat(56)];

type DiscoveryMocks = {
/** every intercepted discovery request, path + query, in order */
requests: string[];
};

async function mockDiscoveryRoutes(
page: Page,
options: { resolveToSigners: boolean },
): Promise<DiscoveryMocks> {
const requests: string[] = [];

await page.route("**/api/v1/lookupMultisigWallet**", async (route) => {
const url = new URL(route.request().url());
requests.push(`${url.pathname}${url.search}`);
const hashes = (url.searchParams.get("pubKeyHashes") ?? "")
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
const participants = Object.fromEntries(
hashes.map((hash, i) => [hash, { name: `Signer ${i + 1}` }]),
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
tx_hash: REGISTRATION_TX,
json_metadata: {
types: [0],
name: "E2E Registered Wallet",
description: "Mocked CIP-0146 registration",
participants,
},
},
]),
});
});

await page.route("**/api/v1/resolveScript**", async (route) => {
const url = new URL(route.request().url());
requests.push(`${url.pathname}${url.search}`);
const scriptHash = url.searchParams.get("scriptHash") ?? "";
const sigHashes = options.resolveToSigners ? FOREIGN_SIG_HASHES : [];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
scriptHash,
stakeCredentialHash: null,
scriptJson: options.resolveToSigners
? {
type: "atLeast",
required: 2,
scripts: sigHashes.map((keyHash) => ({ type: "sig", keyHash })),
}
: null,
sigHashes,
}),
});
});

return { requests };
}

async function openDiscoverTab(page: Page): Promise<void> {
await page.goto("/wallets/import-wallet?tab=discover");
await expect(page.getByText("Discover registered wallets")).toBeVisible({
timeout: 60_000,
});
}

function searchBox(page: Page) {
return page.getByRole("textbox", {
name: "Search by signer or wallet address",
});
}

async function search(page: Page, value: string): Promise<void> {
await searchBox(page).fill(value);
await page.getByRole("button", { name: "Search", exact: true }).click();
}

test.describe("discover tab lookup by signer / policy", () => {
test("lists the connected signer's registered wallet as importable", async ({
page,
authenticateAs,
}) => {
test.setTimeout(120_000);
await authenticateAs(page, 0);
const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true });

await openDiscoverTab(page);

await expect(page.getByText("E2E Registered Wallet")).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("you", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Import", exact: true })).toBeEnabled();
await expect(page.getByText("View only")).toHaveCount(0);
expect(
mocks.requests.some((r) => r.startsWith("/api/v1/lookupMultisigWallet")),
).toBe(true);
});

test("searching another signer's address shows their wallet view-only", async ({
page,
authenticateAs,
}) => {
test.setTimeout(120_000);
const ctx = loadContext();
const otherSigner = ctx.signerAddresses[1];
if (!otherSigner) throw new Error("Bootstrap context needs two signers");

await authenticateAs(page, 0);
const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true });
await openDiscoverTab(page);
await expect(page.getByText("E2E Registered Wallet")).toBeVisible({
timeout: 30_000,
});

await search(page, otherSigner);

await expect(page.getByText(/found for this signer/)).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("match", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "View only" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Import", exact: true })).toHaveCount(0);
await expect(
page.getByText(/isn't a participant of this wallet/),
).toBeVisible();

// The lookup was made with the searched signer's keys, not the user's.
const { resolvePaymentKeyHash } = await import("@meshsdk/core");
const otherHash = resolvePaymentKeyHash(otherSigner).toLowerCase();
expect(
mocks.requests.some(
(r) =>
r.startsWith("/api/v1/lookupMultisigWallet") && r.includes(otherHash),
),
).toBe(true);
});

test("searching a multisig address resolves the script and matches by policy", async ({
page,
authenticateAs,
}) => {
test.setTimeout(120_000);
const ctx = loadContext();
const walletAddress = ctx.wallets[0]?.walletAddress;
if (!walletAddress) throw new Error("Bootstrap context has no wallet address");

await authenticateAs(page, 0);
const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true });
await openDiscoverTab(page);
await expect(page.getByText("E2E Registered Wallet")).toBeVisible({
timeout: 30_000,
});

await search(page, walletAddress);

await expect(page.getByText(/has 2 signers; showing registrations/)).toBeVisible(
{ timeout: 30_000 },
);
await expect(page.getByText(/found for this wallet/)).toBeVisible();
await expect(page.getByRole("button", { name: "View only" })).toBeDisabled();

// Policy path: resolveScript by hash first, then lookup by its signers.
const resolveIdx = mocks.requests.findIndex((r) =>
r.startsWith("/api/v1/resolveScript?scriptHash="),
);
expect(resolveIdx).toBeGreaterThanOrEqual(0);
const followUp = mocks.requests
.slice(resolveIdx + 1)
.find((r) => r.startsWith("/api/v1/lookupMultisigWallet"));
expect(followUp).toBeDefined();
for (const hash of FOREIGN_SIG_HASHES) {
expect(followUp).toContain(hash);
}
});

test("a bare hash that is not a script falls back to a signer lookup", async ({
page,
authenticateAs,
}) => {
test.setTimeout(120_000);
const ctx = loadContext();
const self = ctx.signerAddresses[0];
if (!self) throw new Error("Bootstrap context has no signer address");
const { resolvePaymentKeyHash } = await import("@meshsdk/core");
const selfHash = resolvePaymentKeyHash(self).toLowerCase();

await authenticateAs(page, 0);
const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: false });
await openDiscoverTab(page);
await expect(page.getByText("E2E Registered Wallet")).toBeVisible({
timeout: 30_000,
});

await search(page, selfHash.toUpperCase());

await expect(page.getByText(/found for this hash/)).toBeVisible({
timeout: 30_000,
});
// The echoed registration lists the user's own hash, so it is importable.
await expect(page.getByRole("button", { name: "Import", exact: true })).toBeEnabled();
expect(
mocks.requests.some((r) =>
r.startsWith(`/api/v1/resolveScript?scriptHash=${selfHash}`),
),
).toBe(true);
expect(
mocks.requests.some(
(r) =>
r.startsWith("/api/v1/lookupMultisigWallet") && r.includes(selfHash),
),
).toBe(true);
});

test("malformed input shows an inline error and makes no request", async ({
page,
authenticateAs,
}) => {
test.setTimeout(120_000);
await authenticateAs(page, 0);
const mocks = await mockDiscoveryRoutes(page, { resolveToSigners: true });
await openDiscoverTab(page);
await expect(page.getByText("E2E Registered Wallet")).toBeVisible({
timeout: 30_000,
});
const before = mocks.requests.length;

await search(page, "not-an-address");

// Next's route announcer is also role="alert", so match on the copy.
const inlineError = page.getByText(/Enter a signer address/);
await expect(inlineError).toBeVisible();
await expect(inlineError).toHaveAttribute("role", "alert");
await expect(
page.getByText("Fix the search to look up registrations."),
).toBeVisible();
expect(mocks.requests.length).toBe(before);

// Clear restores the default (own keys) listing.
await page.getByRole("button", { name: "Clear", exact: true }).click();
await expect(inlineError).toHaveCount(0);
await expect(page.getByText("E2E Registered Wallet")).toBeVisible({
timeout: 30_000,
});
});
});
Loading
Loading