Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,9 +234,21 @@ The frontend is built to call the API on its own origin, and nginx proxies `/api
- [ ] Marketplace integration example (check a counterparty's history before a deal)
- [ ] Rate limiting & spam-commitment protections
- [x] Dashboard endpoint (commitments created/fulfilled over time)
- [x] Discord & Slack commitment tracker bot ([`docs/discord-bot.md`](./docs/discord-bot.md))
- [x] Verifiable reputation export — prove `Trust Score > threshold` in zero knowledge
([`docs/zk-reputation-proofs.md`](./docs/zk-reputation-proofs.md))

### Discord & Slack Notification Bot (`pactum-bot`)

DAOs and organizations can receive real-time updates directly in Discord and Slack when commitments are created, fulfilled, late, breached, or disputed:

```bash
cd backend
npm run pactum-bot
```

See the complete [Discord & Slack Bot Setup Guide](./docs/discord-bot.md) for instructions on inviting the bot to your Discord server or configuring incoming webhooks.

Open an issue if you'd like to pick up any of these — contributions welcome.

### Reputation cache
Expand Down
17 changes: 17 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,20 @@ REPUTATION_CACHE_TTL_SECONDS=60
# Optional: External API Keys for monitoring services
# PINGDOM_API_KEY=your_pingdom_api_key
# UPTIMEROBOT_API_KEY=your_uptimerobot_api_key

# ─── Pactum Discord & Slack Bot Worker Configuration ──────────────────────────
# Discord Bot Token and Channel ID for gateway bot delivery (via discord.js)
# DISCORD_BOT_TOKEN=your_discord_bot_token_here
# DISCORD_CHANNEL_ID=123456789012345678

# Discord Incoming Webhook URL (alternative to full bot token)
# DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/.../...

# Slack Incoming Webhook URL
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/.../.../...

# Bot Polling Configuration
# BOT_POLL_INTERVAL_MS=5000
# Optional: Set starting ISO timestamp or cursor for bot event polling
# BOT_START_TIME=2026-01-01T00:00:00.000Z

4 changes: 4 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"test:integration": "tsc -p tsconfig.integration-test.json && find dist-integration-test/tests/integration -type f -name '*.test.js' -exec node --test {} +",
"load:reputation": "node scripts/load-reputation.js",
"indexer": "node dist/workers/indexer.js",
"bot": "node dist/workers/discordBot.js",
"pactum-bot": "node dist/workers/discordBot.js",
"dev:bot": "nodemon src/workers/discordBot.ts",
"analytics:worker": "node dist/workers/analyticsWorker.js",
"migrate:timescale": "node dist/db/migrate.js",
"migrate:up": "npm run build && node dist/db/migrate.js",
Expand All @@ -29,6 +32,7 @@
"dependencies": {
"@stellar/stellar-sdk": "^16.2.0",
"cors": "^2.8.6",
"discord.js": "^14.27.0",
"dotenv": "^17.4.2",
"ethers": "^6.13.0",
"express": "^5.2.1",
Expand Down
275 changes: 275 additions & 0 deletions backend/src/services/botNotificationService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { EmbedBuilder } from 'discord.js';

export interface CommitmentNotificationPayload {
commitmentId: string;
partyA: string;
partyB?: string | null;
amount?: number | string | null;
currency?: string | null;
status: string;
outcome?: string | null;
template?: string | null;
dueDate?: Date | string | null;
completedAt?: Date | string | null;
createdAt?: Date | string | null;
dashboardUrl?: string | null;
}

export function formatAddress(address?: string | null): string {
if (!address) return 'N/A';
if (address.length <= 12) return address;
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}

export function formatDate(date?: Date | string | null): string {
if (!date) return 'N/A';
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return 'N/A';
return d.toUTCString();
}

export function formatAmount(amount?: number | string | null, currency?: string | null): string {
if (amount === undefined || amount === null || amount === '') return 'N/A';
const num = typeof amount === 'number' ? amount : parseFloat(amount);
if (isNaN(num)) return 'N/A';
const curr = currency || 'XLM';
return `${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} ${curr}`;
}

export enum EventType {
CREATED = 'CREATED',
FULFILLED = 'FULFILLED',
LATE = 'LATE',
BREACHED = 'BREACHED',
DISPUTED = 'DISPUTED',
}

export function determineEventType(data: CommitmentNotificationPayload): EventType {
const outcome = (data.outcome || '').toLowerCase();
const status = (data.status || '').toLowerCase();

if (outcome === 'disputed' || status === 'disputed') {
return EventType.DISPUTED;
}
if (outcome === 'late' || status === 'late') {
return EventType.LATE;
}
if (outcome === 'breached' || status === 'breached') {
return EventType.BREACHED;
}
if (outcome === 'fulfilled' || status === 'fulfilled' || status === 'completed') {
return EventType.FULFILLED;
}
return EventType.CREATED;
}

export interface DiscordEmbedData {
title: string;
description: string;
color: number;
fields: { name: string; value: string; inline?: boolean }[];
footer: { text: string };
timestamp: string;
url?: string;
}

/**
* Builds a rich Discord Embed (compatible with both Discord.js EmbedBuilder and Webhooks).
*/
export function buildDiscordEmbed(data: CommitmentNotificationPayload): EmbedBuilder {
const eventType = determineEventType(data);
const embed = new EmbedBuilder();
const baseUrl = data.dashboardUrl || process.env.DASHBOARD_URL || 'https://pactum.network';
const commitmentUrl = `${baseUrl.replace(/\/$/, '')}/commitments/${data.commitmentId}`;

const partyAFormatted = formatAddress(data.partyA);
const partyBFormatted = formatAddress(data.partyB);
const amountFormatted = formatAmount(data.amount, data.currency);
const dueDateFormatted = formatDate(data.dueDate);

embed.setURL(commitmentUrl);
embed.setTimestamp();
embed.setFooter({ text: 'Pactum Trust Layer • Stellar Soroban' });

switch (eventType) {
case EventType.DISPUTED:
embed.setTitle(`⚠️ Commitment #${data.commitmentId} has been DISPUTED`);
embed.setDescription(
`🚨 **Dispute Alert**: Participating parties have reported a dispute on Commitment **#${data.commitmentId}**. Action or oracle arbitration may be required.`,
);
embed.setColor(0xe74c3c); // Red / Alert
embed.addFields(
{ name: 'Issuer (Party A)', value: `\`${partyAFormatted}\``, inline: true },
{ name: 'Counterparty (Party B)', value: `\`${partyBFormatted}\``, inline: true },
{ name: 'Status', value: '⚠️ **DISPUTED**', inline: true },
{ name: 'Amount', value: amountFormatted, inline: true },
{ name: 'Due Date', value: dueDateFormatted, inline: true },
{ name: 'Template', value: data.template || 'Custom Agreement', inline: true },
);
break;

case EventType.FULFILLED:
embed.setTitle(`✅ Commitment #${data.commitmentId} FULFILLED`);
embed.setDescription(
`🎉 Commitment **#${data.commitmentId}** was successfully fulfilled on time. Trust score updated.`,
);
embed.setColor(0x2ecc71); // Green
embed.addFields(
{ name: 'Issuer (Party A)', value: `\`${partyAFormatted}\``, inline: true },
{ name: 'Counterparty (Party B)', value: `\`${partyBFormatted}\``, inline: true },
{ name: 'Outcome', value: '✅ **Fulfilled (On Time)**', inline: true },
{ name: 'Amount', value: amountFormatted, inline: true },
{
name: 'Completed At',
value: formatDate(data.completedAt || data.createdAt),
inline: true,
},
{ name: 'Template', value: data.template || 'Custom Agreement', inline: true },
);
break;

case EventType.LATE:
embed.setTitle(`⏰ Commitment #${data.commitmentId} Fulfilled LATE`);
embed.setDescription(
`⚠️ Commitment **#${data.commitmentId}** was fulfilled after the agreed deadline.`,
);
embed.setColor(0xf1c40f); // Yellow
embed.addFields(
{ name: 'Issuer (Party A)', value: `\`${partyAFormatted}\``, inline: true },
{ name: 'Counterparty (Party B)', value: `\`${partyBFormatted}\``, inline: true },
{ name: 'Outcome', value: '⏰ **Late Delivery**', inline: true },
{ name: 'Amount', value: amountFormatted, inline: true },
{
name: 'Completed At',
value: formatDate(data.completedAt || data.createdAt),
inline: true,
},
{ name: 'Due Date', value: dueDateFormatted, inline: true },
);
break;

case EventType.BREACHED:
embed.setTitle(`❌ Commitment #${data.commitmentId} BREACHED`);
embed.setDescription(
`❌ Commitment **#${data.commitmentId}** expired or was breached without fulfillment. Trust score penalised.`,
);
embed.setColor(0x992d22); // Dark Red
embed.addFields(
{ name: 'Issuer (Party A)', value: `\`${partyAFormatted}\``, inline: true },
{ name: 'Counterparty (Party B)', value: `\`${partyBFormatted}\``, inline: true },
{ name: 'Outcome', value: '❌ **Breached**', inline: true },
{ name: 'Amount', value: amountFormatted, inline: true },
{ name: 'Due Date', value: dueDateFormatted, inline: true },
{ name: 'Template', value: data.template || 'Custom Agreement', inline: true },
);
break;

case EventType.CREATED:
default:
embed.setTitle(`📋 New Commitment Created • #${data.commitmentId}`);
embed.setDescription(
`✨ A new commitment has been registered on-chain on the Pactum Trust Layer.`,
);
embed.setColor(0x3498db); // Blue
embed.addFields(
{ name: 'Issuer (Party A)', value: `\`${partyAFormatted}\``, inline: true },
{ name: 'Counterparty (Party B)', value: `\`${partyBFormatted}\``, inline: true },
{ name: 'Status', value: '🟢 **Active / Pending**', inline: true },
{ name: 'Amount', value: amountFormatted, inline: true },
{ name: 'Due Date', value: dueDateFormatted, inline: true },
{ name: 'Template', value: data.template || 'Custom Agreement', inline: true },
Comment thread
DhruvC-2938 marked this conversation as resolved.
Resolved
);
break;
}

return embed;
}

/**
* Builds a Slack Block Kit payload representing the event.
*/
export function buildSlackPayload(data: CommitmentNotificationPayload): Record<string, any> {
const eventType = determineEventType(data);
const partyAFormatted = formatAddress(data.partyA);
const partyBFormatted = formatAddress(data.partyB);
const amountFormatted = formatAmount(data.amount, data.currency);
const dueDateFormatted = formatDate(data.dueDate);
const baseUrl = data.dashboardUrl || process.env.DASHBOARD_URL || 'https://pactum.network';
const commitmentUrl = `${baseUrl.replace(/\/$/, '')}/commitments/${data.commitmentId}`;

let headerText = `📋 New Commitment #${data.commitmentId}`;
let statusText = `*Status:* Active / Pending`;

if (eventType === EventType.DISPUTED) {
headerText = `⚠️ Commitment #${data.commitmentId} has been DISPUTED`;
statusText = `*Status:* ⚠️ *DISPUTED*`;
} else if (eventType === EventType.FULFILLED) {
headerText = `✅ Commitment #${data.commitmentId} FULFILLED`;
statusText = `*Outcome:* ✅ *Fulfilled (On Time)*`;
} else if (eventType === EventType.LATE) {
headerText = `⏰ Commitment #${data.commitmentId} Fulfilled LATE`;
statusText = `*Outcome:* ⏰ *Late Delivery*`;
} else if (eventType === EventType.BREACHED) {
headerText = `❌ Commitment #${data.commitmentId} BREACHED`;
statusText = `*Outcome:* ❌ *Breached*`;
}

return {
text: `${headerText} - View details: ${commitmentUrl}`,
blocks: [
{
type: 'header',
text: {
type: 'plain_text',
text: headerText,
emoji: true,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `<${commitmentUrl}|*View Commitment #${data.commitmentId} on Pactum Dashboard*>`,
},
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Issuer (Party A):*\n\`${partyAFormatted}\``,
},
{
type: 'mrkdwn',
text: `*Counterparty (Party B):*\n\`${partyBFormatted}\``,
},
{
type: 'mrkdwn',
text: statusText,
},
{
type: 'mrkdwn',
text: `*Amount:*\n${amountFormatted}`,
},
{
type: 'mrkdwn',
text: `*Due Date:*\n${dueDateFormatted}`,
},
{
type: 'mrkdwn',
text: `*Template:*\n${data.template || 'Custom Agreement'}`,
},
],
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `🛡️ *Pactum Trust Layer* • Soroban Stellar • ${new Date().toUTCString()}`,
},
],
},
],
};
}
Loading
Loading