diff --git a/components/polls/index.js b/components/polls/index.js
index 98139b1..b0f11a0 100644
--- a/components/polls/index.js
+++ b/components/polls/index.js
@@ -1,7 +1,7 @@
const md5 = require('md5');
const mongoose = require("mongoose");
-const { getPkhfromPk } = require("@taquito/utils");
+const { getPkhfromPk, validateAddress, ValidationResult } = require("@taquito/utils");
const {
getInputFromSigPayload,
getCurrentBlock,
@@ -19,6 +19,39 @@ const ChoiceModel = require("../../db/models/Choice.model");
const { getEthCurrentBlockNumber, getEthTotalSupply } = require("../../utils-eth");
+/**
+ * Validate the optional funding request carried inside a signed poll payload.
+ * Returns undefined when nothing was requested, throws when the shape is wrong.
+ *
+ * @param {{recipient: string, amount: string}} [fundingRequest]
+ * @returns {{recipient: string, amount: string}|undefined}
+ */
+function validateFundingRequest(fundingRequest) {
+ if (fundingRequest === undefined || fundingRequest === null) {
+ return undefined;
+ }
+
+ if (typeof fundingRequest !== "object" || Array.isArray(fundingRequest)) {
+ throw new Error("Invalid funding request");
+ }
+
+ const { recipient, amount } = fundingRequest;
+
+ if (typeof recipient !== "string" || validateAddress(recipient) !== ValidationResult.VALID) {
+ throw new Error("Invalid funding request recipient");
+ }
+
+ // Decimal string with at most 6 decimals, strictly greater than zero.
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
+ throw new Error("Invalid funding request amount");
+ }
+ if (Number(amount) <= 0) {
+ throw new Error("Invalid funding request amount");
+ }
+
+ return { recipient, amount: amount.trim() };
+}
+
function validateExternalLink(externalLink) {
if (!externalLink || typeof externalLink !== 'string') {
return '';
@@ -181,6 +214,7 @@ const addPoll = async (req, response) => {
isXTZ,
} = payload;
const daoID = payload?.daoID || payload?.daoId;
+ const fundingRequest = validateFundingRequest(payload?.fundingRequest);
console.log("Payload", payload)
if (choices.length === 0) {
throw new Error("No choices sent in the request");
@@ -238,6 +272,7 @@ const addPoll = async (req, response) => {
payloadBytes,
payloadBytesHash,
cidLink: "",
+ ...(fundingRequest ? { fundingRequest } : {}),
};
const createdPoll = await PollModel.create(PollData);
@@ -305,6 +340,8 @@ const addPoll = async (req, response) => {
isXTZ,
} = values;
+ const fundingRequest = validateFundingRequest(values?.fundingRequest);
+
const author = getPkhfromPk(publicKey);
const currentTime = new Date().valueOf();
@@ -387,6 +424,7 @@ const addPoll = async (req, response) => {
payloadBytes,
signature,
cidLink: "",
+ ...(fundingRequest ? { fundingRequest } : {}),
};
const createdPoll = await PollModel.create([PollData], { session });
@@ -433,8 +471,97 @@ const addPoll = async (req, response) => {
}
};
+/**
+ * POST /polls/:id/link-proposal
+ *
+ * Records the on-chain proposal a lite poll was promoted to. The request is
+ * signed the same way poll creation and voting are, so requireSignature has
+ * already verified the signature by the time we get here; what is left is to
+ * check that the signer is allowed to link this particular poll.
+ *
+ * Authorized signers: the poll author, or the creator/admin of the poll's
+ * community. The DAO model has no dedicated admin field; the creator is the
+ * first entry of members (see createDAO, which seeds members with the
+ * creator's address), so that entry is treated as the admin.
+ */
+const linkProposal = async (req, response) => {
+ const { payloadBytes, publicKey } = req.body;
+ const network = req.body.network;
+ const { id } = req.params;
+
+ try {
+ let payload = req.payloadObj;
+ if (!payload) {
+ payload = getInputFromSigPayload(payloadBytes);
+ }
+
+ const { daoAddress, proposalKey } = payload || {};
+ const proposalNetwork = payload?.network || network;
+
+ if (typeof daoAddress !== "string" || validateAddress(daoAddress) !== ValidationResult.VALID) {
+ throw new Error("Invalid DAO address");
+ }
+ if (typeof proposalKey !== "string" || !proposalKey.trim()) {
+ throw new Error("Invalid proposal key");
+ }
+ if (typeof proposalNetwork !== "string" || !proposalNetwork.trim()) {
+ throw new Error("Invalid network");
+ }
+
+ if (!mongoose.isValidObjectId(id)) {
+ return response.status(404).send({ message: "Poll not found" });
+ }
+
+ const poll = await PollModel.findById(id);
+ if (!poll) {
+ return response.status(404).send({ message: "Poll not found" });
+ }
+
+ // Etherlink signs with the address itself, Tezos with the public key.
+ const signer = network?.startsWith("etherlink")
+ ? publicKey
+ : getPkhfromPk(publicKey);
+
+ const isAuthor =
+ typeof poll.author === "string" &&
+ poll.author.toLowerCase() === String(signer).toLowerCase();
+
+ let isDaoAdmin = false;
+ if (!isAuthor) {
+ const daoQuery = mongoose.isValidObjectId(poll.daoID)
+ ? { _id: poll.daoID }
+ : { address: { $regex: new RegExp(`^${poll.daoID}$`, "i") } };
+ const dao = await DaoModel.findOne(daoQuery).lean();
+ // members[0] is the DAO creator, treated as the community admin.
+ const admin = dao?.members?.[0];
+ isDaoAdmin =
+ Boolean(admin) && String(admin).toLowerCase() === String(signer).toLowerCase();
+ }
+
+ if (!isAuthor && !isDaoAdmin) {
+ return response.status(403).send({ message: "Not authorized to link this poll" });
+ }
+
+ poll.onchainProposal = {
+ daoAddress,
+ proposalKey: proposalKey.trim(),
+ network: proposalNetwork.trim(),
+ };
+ await poll.save();
+
+ return response.status(200).json({ ok: true });
+ } catch (error) {
+ console.log("error: ", error);
+ return response.status(400).send({
+ message: error.message,
+ });
+ }
+};
+
module.exports = {
getPollById,
getPollsById,
addPoll,
+ linkProposal,
+ validateFundingRequest,
};
diff --git a/components/subscriptions/index.js b/components/subscriptions/index.js
new file mode 100644
index 0000000..c9e6a16
--- /dev/null
+++ b/components/subscriptions/index.js
@@ -0,0 +1,265 @@
+const crypto = require("crypto");
+
+const { validateAddress, ValidationResult } = require("@taquito/utils");
+
+const SubscriptionModel = require("../../db/models/Subscription.model");
+const {
+ sendMail,
+ getAppUrl,
+ getPublicApiUrl,
+} = require("../../services/mailer.service");
+
+// Networks the app supports for Tezos DAO alerts.
+const SUPPORTED_NETWORKS = ["mainnet", "ghostnet", "shadownet"];
+
+const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}$/;
+
+const RATE_LIMIT_MAX = 5;
+const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
+
+/**
+ * Per-email throttle for subscription requests.
+ *
+ * NOTE: this map lives in the memory of a single serverless container, so it
+ * only throttles requests that happen to reach the same warm instance and it
+ * is reset on every cold start. It is a cheap first line of defence against
+ * accidental loops and casual abuse, not a real rate limiter. A durable
+ * limiter would need a shared store (Mongo TTL collection or Redis).
+ */
+const rateLimitBuckets = new Map();
+
+const isRateLimited = (email) => {
+ const now = Date.now();
+ const hits = (rateLimitBuckets.get(email) || []).filter(
+ (timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS
+ );
+
+ if (hits.length >= RATE_LIMIT_MAX) {
+ rateLimitBuckets.set(email, hits);
+ return true;
+ }
+
+ hits.push(now);
+ rateLimitBuckets.set(email, hits);
+
+ // Opportunistic cleanup so the map does not grow without bound.
+ if (rateLimitBuckets.size > 5000) {
+ for (const [key, timestamps] of rateLimitBuckets) {
+ if (timestamps.every((timestamp) => now - timestamp >= RATE_LIMIT_WINDOW_MS)) {
+ rateLimitBuckets.delete(key);
+ }
+ }
+ }
+
+ return false;
+};
+
+const resetRateLimit = () => rateLimitBuckets.clear();
+
+const generateToken = () => crypto.randomBytes(32).toString("hex");
+
+const isValidEmail = (email) =>
+ typeof email === "string" && email.length <= 254 && EMAIL_REGEX.test(email.trim());
+
+const isValidContractAddress = (address) =>
+ typeof address === "string" &&
+ address.startsWith("KT1") &&
+ validateAddress(address) === ValidationResult.VALID;
+
+const isSupportedNetwork = (network) =>
+ typeof network === "string" && SUPPORTED_NETWORKS.includes(network.toLowerCase());
+
+const unsubscribeUrl = (token) =>
+ `${getPublicApiUrl()}/subscriptions/unsubscribe/${token}`;
+
+const confirmUrl = (token) =>
+ `${getPublicApiUrl()}/subscriptions/confirm/${token}`;
+
+const escapeHtml = (value) =>
+ String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+
+// Optional display name sent by the frontend; plain text only, kept short.
+const cleanDaoName = (value) =>
+ typeof value === "string"
+ ? value.replace(/[\r\n\t<>]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80)
+ : "";
+
+const buildConfirmationEmail = ({ daoName, daoAddress, network, confirmToken, unsubscribeToken }) => {
+ const confirmLink = confirmUrl(confirmToken);
+ const unsubLink = unsubscribeUrl(unsubscribeToken);
+ const daoLabel = daoName ? `${daoName} (${daoAddress})` : daoAddress;
+ const explanation = `You asked to receive Homebase alerts for the DAO ${daoLabel} on ${network}.`;
+
+ const text = [
+ explanation,
+ "",
+ `Confirm your subscription: ${confirmLink}`,
+ "",
+ "If you did not request this, ignore this email and nothing will be sent.",
+ `Unsubscribe: ${unsubLink}`,
+ ].join("\n");
+
+ const html = [
+ `
${escapeHtml(explanation)}
`,
+ `Confirm your subscription
`,
+ "If you did not request this, ignore this email and nothing will be sent.
",
+ `Unsubscribe
`,
+ ].join("\n");
+
+ return {
+ // Timestamp keeps each confirmation in its own mail thread; Gmail otherwise
+ // groups same-subject emails and shows the oldest (possibly dead) link first.
+ subject: `Confirm Homebase alerts for ${daoName || daoAddress} (${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC)`,
+ text,
+ html,
+ headers: {
+ "List-Unsubscribe": `<${unsubLink}>`,
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
+ },
+ };
+};
+
+/**
+ * POST /subscriptions
+ * Always answers 200 { ok: true } for well formed input so that the endpoint
+ * cannot be used to probe whether an address is already subscribed.
+ */
+const createSubscription = async (req, response) => {
+ const { email, daoAddress, network } = req.body || {};
+ const daoName = cleanDaoName((req.body || {}).daoName);
+
+ if (!isValidEmail(email)) {
+ return response.status(400).json({ message: "Invalid email address" });
+ }
+ if (!isValidContractAddress(daoAddress)) {
+ return response.status(400).json({ message: "Invalid DAO contract address" });
+ }
+ if (!isSupportedNetwork(network)) {
+ return response.status(400).json({ message: "Invalid network" });
+ }
+
+ const normalizedEmail = email.trim().toLowerCase();
+ const normalizedNetwork = network.toLowerCase();
+
+ if (isRateLimited(normalizedEmail)) {
+ return response.status(429).json({ message: "Too many subscription requests" });
+ }
+
+ try {
+ let subscription = await SubscriptionModel.findOne({
+ email: normalizedEmail,
+ daoAddress,
+ network: normalizedNetwork,
+ });
+
+ if (!subscription) {
+ subscription = await SubscriptionModel.create({
+ email: normalizedEmail,
+ daoAddress,
+ network: normalizedNetwork,
+ confirmed: false,
+ confirmToken: generateToken(),
+ unsubscribeToken: generateToken(),
+ });
+ } else if (!subscription.confirmed) {
+ // Keep the existing confirmation token: mail clients thread repeated
+ // confirmation emails together and often surface the older one, so a
+ // rotated token would turn the visible link into a dead one.
+ if (!subscription.confirmToken) {
+ subscription.confirmToken = generateToken();
+ }
+ if (!subscription.unsubscribeToken) {
+ subscription.unsubscribeToken = generateToken();
+ }
+ if (subscription.isModified()) {
+ await subscription.save();
+ }
+ }
+
+ if (!subscription.confirmed) {
+ const mail = buildConfirmationEmail({
+ daoName,
+ daoAddress: subscription.daoAddress,
+ network: subscription.network,
+ confirmToken: subscription.confirmToken,
+ unsubscribeToken: subscription.unsubscribeToken,
+ });
+ await sendMail({ to: subscription.email, ...mail });
+ }
+
+ return response.status(200).json({ ok: true });
+ } catch (error) {
+ console.log("error: ", error);
+ return response.status(400).send({
+ message: error.message,
+ });
+ }
+};
+
+/**
+ * GET /subscriptions/confirm/:token
+ */
+const confirmSubscription = async (req, response) => {
+ const { token } = req.params;
+
+ try {
+ const subscription = await SubscriptionModel.findOne({ confirmToken: token });
+
+ if (!subscription) {
+ return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`);
+ }
+
+ if (!subscription.confirmed) {
+ subscription.confirmed = true;
+ subscription.confirmedAt = new Date();
+ await subscription.save();
+ }
+
+ return response.redirect(
+ 302,
+ `${getAppUrl()}/explorer/dao/${subscription.daoAddress}?alerts=confirmed`
+ );
+ } catch (error) {
+ console.log("error: ", error);
+ return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`);
+ }
+};
+
+/**
+ * GET /subscriptions/unsubscribe/:token
+ */
+const unsubscribeSubscription = async (req, response) => {
+ const { token } = req.params;
+
+ try {
+ const subscription = await SubscriptionModel.findOneAndDelete({
+ unsubscribeToken: token,
+ });
+
+ if (!subscription) {
+ return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`);
+ }
+
+ return response.redirect(
+ 302,
+ `${getAppUrl()}/explorer/dao/${subscription.daoAddress}?alerts=unsubscribed`
+ );
+ } catch (error) {
+ console.log("error: ", error);
+ return response.redirect(302, `${getAppUrl()}/explorer/daos?alerts=invalid`);
+ }
+};
+
+module.exports = {
+ createSubscription,
+ confirmSubscription,
+ unsubscribeSubscription,
+ SUPPORTED_NETWORKS,
+ unsubscribeUrl,
+ confirmUrl,
+ resetRateLimit,
+};
diff --git a/config.env.example b/config.env.example
index d013676..c535c42 100644
--- a/config.env.example
+++ b/config.env.example
@@ -1,3 +1,14 @@
ATLAS_URI=#YOUR_MONGODB_URI
PORT=#YOUR_NONDEFAULT_APP_PORT
-NFT_STORAGE_KEY=XXXXXX #TokenFrom Nft.Storage
\ No newline at end of file
+NFT_STORAGE_KEY=XXXXXX #TokenFrom Nft.Storage
+
+# Email alerts (DAO subscriptions). Leave SMTP_HOST empty to disable sending.
+SMTP_HOST=#smtp.example.com
+SMTP_PORT=587 #465 for implicit TLS, anything else uses STARTTLS
+SMTP_USER=#YOUR_SMTP_USERNAME
+SMTP_PASS=#YOUR_SMTP_PASSWORD
+MAIL_FROM=Homebase Alerts
+
+# Public URLs used to build links inside outgoing mail
+APP_URL=https://tezos-homebase.io
+PUBLIC_API_URL=https://homebase-backend.netlify.app
diff --git a/db/models/Poll.model.js b/db/models/Poll.model.js
index ea2cb66..6f90083 100644
--- a/db/models/Poll.model.js
+++ b/db/models/Poll.model.js
@@ -60,6 +60,39 @@ const PollModelSchema = new Schema({
type: String,
index: true,
sparse: true,
+ },
+ // Optional treasury transfer this poll is asking for.
+ fundingRequest: {
+ type: new Schema({
+ recipient: {
+ type: String,
+ required: true,
+ },
+ // Decimal string, at most 6 decimal places
+ amount: {
+ type: String,
+ required: true,
+ },
+ }, { _id: false }),
+ default: undefined,
+ },
+ // Set once the poll has been promoted to an on-chain DAO proposal.
+ onchainProposal: {
+ type: new Schema({
+ daoAddress: {
+ type: String,
+ required: true,
+ },
+ proposalKey: {
+ type: String,
+ required: true,
+ },
+ network: {
+ type: String,
+ required: true,
+ },
+ }, { _id: false }),
+ default: undefined,
}
},{
timestamps: true,
diff --git a/db/models/Subscription.model.js b/db/models/Subscription.model.js
new file mode 100644
index 0000000..51b68ed
--- /dev/null
+++ b/db/models/Subscription.model.js
@@ -0,0 +1,56 @@
+const mongoose = require('mongoose');
+
+const Schema = mongoose.Schema;
+
+const SubscriptionModelSchema = new Schema({
+ email: {
+ type: String,
+ required: true,
+ lowercase: true,
+ trim: true,
+ },
+ daoAddress: {
+ type: String,
+ required: true,
+ },
+ network: {
+ type: String,
+ required: true,
+ },
+ confirmed: {
+ type: Boolean,
+ default: false,
+ },
+ confirmToken: {
+ type: String,
+ index: true,
+ sparse: true,
+ },
+ unsubscribeToken: {
+ type: String,
+ index: true,
+ sparse: true,
+ },
+ createdAt: {
+ type: Date,
+ default: Date.now,
+ },
+ confirmedAt: {
+ type: Date,
+ default: null,
+ },
+});
+
+// One subscription per (email, DAO, network) triple.
+SubscriptionModelSchema.index(
+ { email: 1, daoAddress: 1, network: 1 },
+ { unique: true }
+);
+
+const SubscriptionModel = mongoose.model(
+ 'Subscription',
+ SubscriptionModelSchema,
+ 'Subscriptions'
+);
+
+module.exports = SubscriptionModel;
diff --git a/package-lock.json b/package-lock.json
index 4e400cd..3b101f4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"mongoose": "^8.5.2",
"nanoid": "^3.3.7",
"nft.storage": "^7.1.1",
+ "nodemailer": "^10.0.9",
"persistent-cache": "^1.1.2",
"serverless-http": "^3.2.0",
"swagger-jsdoc": "^6.2.8",
@@ -6860,6 +6861,15 @@
"node.extend": "1.0.8"
}
},
+ "node_modules/nodemailer": {
+ "version": "10.0.9",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.9.tgz",
+ "integrity": "sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz",
diff --git a/package.json b/package.json
index e365f28..9c01703 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"mongoose": "^8.5.2",
"nanoid": "^3.3.7",
"nft.storage": "^7.1.1",
+ "nodemailer": "^10.0.9",
"persistent-cache": "^1.1.2",
"serverless-http": "^3.2.0",
"swagger-jsdoc": "^6.2.8",
diff --git a/routes/polls.js b/routes/polls.js
index 22d0e83..06e89d2 100644
--- a/routes/polls.js
+++ b/routes/polls.js
@@ -6,7 +6,7 @@ const express = require("express");
const pollsRoutes = express.Router();
const { requireSignature } = require("../middlewares");
-const { getPollsById, getPollById, addPoll } = require("../components/polls");
+const { getPollsById, getPollById, addPoll, linkProposal } = require("../components/polls");
/**
* @swagger
@@ -71,5 +71,43 @@ pollsRoutes.route("/polls/:id/list").get(getPollsById);
* description: Invalid signature payload
*/
pollsRoutes.route("/poll/add").all(requireSignature).post(addPoll);
+/**
+ * @swagger
+ * /polls/{id}/link-proposal:
+ * post:
+ * summary: Link a poll to the on-chain proposal it was promoted to
+ * tags: [Polls]
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * description: The ID of the poll
+ * schema:
+ * type: string
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * payloadBytes:
+ * type: string
+ * description: Signed payload holding daoAddress, proposalKey and network
+ * publicKey:
+ * type: string
+ * signature:
+ * type: string
+ * responses:
+ * 200:
+ * description: Proposal linked successfully
+ * 400:
+ * description: Invalid signature payload or proposal details
+ * 403:
+ * description: Signer is neither the poll author nor the community admin
+ * 404:
+ * description: Poll not found
+ */
+pollsRoutes.route("/polls/:id/link-proposal").all(requireSignature).post(linkProposal);
module.exports = pollsRoutes;
diff --git a/routes/polls.test.js b/routes/polls.test.js
index 4300e58..8c90215 100644
--- a/routes/polls.test.js
+++ b/routes/polls.test.js
@@ -1,12 +1,41 @@
const request = require("supertest");
const express = require("express");
+const mongoose = require("mongoose");
+const { char2Bytes } = require("@taquito/utils");
+
const pollsRoutes = require("./polls");
+const { linkProposal, validateFundingRequest } = require("../components/polls");
+const PollModel = require("../db/models/Poll.model");
+const DaoModel = require("../db/models/Dao.model");
+const { connectToMongoose } = require("../db/mongoose-connection");
+const { createTestDAO, createTestPoll } = require("../tests/fixtures/test-data");
const app = express();
app.use(express.json());
app.use("/", pollsRoutes);
const id = 123;
+// Real Tezos addresses, needed because validateAddress verifies the checksum.
+const DAO_ADDRESS = "KT1T17GC91HrJ8ijZgnMaE9j4PZbojbVAn73";
+const RECIPIENT = "tz1burnburnburnburnburnburnburjAYjjX";
+const AUTHOR = "tz1TestUser1";
+const DAO_ADMIN = "tz1TestUser1";
+
+const signedPayload = (data) =>
+ char2Bytes(
+ `Tezos Signed Message: homebase.com ${new Date().toISOString()} ${JSON.stringify(data)}`
+ );
+
+/**
+ * linkProposal is mounted behind requireSignature in the real router, which
+ * needs a genuine wallet signature. These tests exercise the component
+ * directly so the authorization rules can be checked without a signer, and
+ * the route level test below covers the signature gate itself.
+ */
+const linkProposalApp = express();
+linkProposalApp.use(express.json());
+linkProposalApp.post("/polls/:id/link-proposal", linkProposal);
+
describe("Polls Routes", () => {
it("should not get a poll with an invalid id", async () => {
await request(app)
@@ -23,4 +52,238 @@ describe("Polls Routes", () => {
it("should not add a poll with an invalid signature payload", async () => {
await request(app).post(`/poll/add`).send("").expect(500);
});
+ it("should not link a proposal with an invalid signature payload", async () => {
+ await request(app).post(`/polls/${id}/link-proposal`).send("").expect(500);
+ });
+});
+
+describe("validateFundingRequest", () => {
+ it("should return undefined when no funding request is present", () => {
+ expect(validateFundingRequest(undefined)).toBeUndefined();
+ expect(validateFundingRequest(null)).toBeUndefined();
+ });
+
+ it("should accept a valid recipient and amount", () => {
+ expect(validateFundingRequest({ recipient: RECIPIENT, amount: "12.5" })).toEqual({
+ recipient: RECIPIENT,
+ amount: "12.5",
+ });
+ });
+
+ it("should accept a KT1 recipient", () => {
+ expect(validateFundingRequest({ recipient: DAO_ADDRESS, amount: "1" })).toEqual({
+ recipient: DAO_ADDRESS,
+ amount: "1",
+ });
+ });
+
+ it("should accept exactly six decimals", () => {
+ expect(validateFundingRequest({ recipient: RECIPIENT, amount: "0.000001" })).toEqual({
+ recipient: RECIPIENT,
+ amount: "0.000001",
+ });
+ });
+
+ it("should reject an invalid recipient", () => {
+ expect(() =>
+ validateFundingRequest({ recipient: "not-an-address", amount: "1" })
+ ).toThrow("Invalid funding request recipient");
+ });
+
+ it("should reject more than six decimals", () => {
+ expect(() =>
+ validateFundingRequest({ recipient: RECIPIENT, amount: "0.0000001" })
+ ).toThrow("Invalid funding request amount");
+ });
+
+ it("should reject a zero or negative amount", () => {
+ expect(() => validateFundingRequest({ recipient: RECIPIENT, amount: "0" })).toThrow(
+ "Invalid funding request amount"
+ );
+ expect(() => validateFundingRequest({ recipient: RECIPIENT, amount: "-5" })).toThrow(
+ "Invalid funding request amount"
+ );
+ });
+
+ it("should reject a non string amount", () => {
+ expect(() => validateFundingRequest({ recipient: RECIPIENT, amount: 5 })).toThrow(
+ "Invalid funding request amount"
+ );
+ });
+});
+
+describe("Poll funding request and proposal linking", () => {
+ // Jest workers share one in-memory Mongo, so this suite only removes the
+ // documents it created rather than emptying the collections.
+ const createdPollIds = [];
+ const createdDaoIds = [];
+
+ const makeDao = async (overrides) => {
+ const dao = await DaoModel.create(createTestDAO(overrides));
+ createdDaoIds.push(dao._id);
+ return dao;
+ };
+
+ const makePoll = async (daoId, overrides) => {
+ const poll = await PollModel.create(createTestPoll(daoId, overrides));
+ createdPollIds.push(poll._id);
+ return poll;
+ };
+
+ beforeAll(async () => {
+ await connectToMongoose();
+ });
+
+ afterAll(async () => {
+ await PollModel.deleteMany({ _id: { $in: createdPollIds } });
+ await DaoModel.deleteMany({ _id: { $in: createdDaoIds } });
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.connection.close();
+ }
+ });
+
+ it("should persist a funding request on the poll document", async () => {
+ const dao = await makeDao();
+ const poll = await makePoll(dao._id, { fundingRequest: { recipient: RECIPIENT, amount: "100.5" } });
+
+ const saved = await PollModel.findById(poll._id).lean();
+ expect(saved.fundingRequest).toEqual({ recipient: RECIPIENT, amount: "100.5" });
+ });
+
+ it("should return the funding request from GET /polls/:id/polls", async () => {
+ const dao = await makeDao();
+ const poll = await makePoll(dao._id, { fundingRequest: { recipient: RECIPIENT, amount: "7" } });
+
+ const response = await request(app).get(`/polls/${poll._id}/polls`).expect(200);
+ expect(response.body.fundingRequest).toEqual({ recipient: RECIPIENT, amount: "7" });
+ });
+
+ it("should return the funding request from GET /polls/:id/list", async () => {
+ const dao = await makeDao();
+ await makePoll(dao._id, { fundingRequest: { recipient: RECIPIENT, amount: "7" } });
+
+ const response = await request(app).get(`/polls/${dao._id}/list`).expect(200);
+ expect(response.body[0].fundingRequest).toEqual({ recipient: RECIPIENT, amount: "7" });
+ });
+
+ it("should leave fundingRequest unset when no funding is requested", async () => {
+ const dao = await makeDao();
+ const poll = await makePoll(dao._id);
+
+ const saved = await PollModel.findById(poll._id).lean();
+ expect(saved.fundingRequest).toBeUndefined();
+ });
+
+ describe("POST /polls/:id/link-proposal", () => {
+ const proposalPayload = (overrides = {}) => ({
+ daoAddress: DAO_ADDRESS,
+ proposalKey: "0xproposalkey",
+ network: "mainnet",
+ ...overrides,
+ });
+
+ it("should link a proposal when the signer is the poll author", async () => {
+ const dao = await makeDao();
+ const poll = await makePoll(dao._id, { author: AUTHOR });
+
+ const payload = proposalPayload();
+ const response = await request(linkProposalApp)
+ .post(`/polls/${poll._id}/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(payload),
+ publicKey: AUTHOR,
+ network: "etherlink-mainnet",
+ })
+ .expect(200);
+
+ expect(response.body).toEqual({ ok: true });
+
+ const saved = await PollModel.findById(poll._id).lean();
+ expect(saved.onchainProposal).toEqual(payload);
+ });
+
+ it("should link a proposal when the signer is the community admin", async () => {
+ const dao = await makeDao({ members: [DAO_ADMIN, "tz1Other"] });
+ const poll = await makePoll(dao._id, { author: "tz1SomebodyElse" });
+
+ await request(linkProposalApp)
+ .post(`/polls/${poll._id}/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(proposalPayload()),
+ publicKey: DAO_ADMIN,
+ network: "etherlink-mainnet",
+ })
+ .expect(200);
+
+ const saved = await PollModel.findById(poll._id).lean();
+ expect(saved.onchainProposal.proposalKey).toBe("0xproposalkey");
+ });
+
+ it("should return 403 when the signer is neither author nor admin", async () => {
+ const dao = await makeDao({ members: ["tz1Admin"] });
+ const poll = await makePoll(dao._id, { author: "tz1SomebodyElse" });
+
+ await request(linkProposalApp)
+ .post(`/polls/${poll._id}/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(proposalPayload()),
+ publicKey: "tz1Intruder",
+ network: "etherlink-mainnet",
+ })
+ .expect(403);
+
+ const saved = await PollModel.findById(poll._id).lean();
+ expect(saved.onchainProposal).toBeUndefined();
+ });
+
+ it("should return 404 for an unknown poll", async () => {
+ await request(linkProposalApp)
+ .post(`/polls/${new mongoose.Types.ObjectId()}/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(proposalPayload()),
+ publicKey: AUTHOR,
+ network: "etherlink-mainnet",
+ })
+ .expect(404);
+ });
+
+ it("should return 404 for a malformed poll id", async () => {
+ await request(linkProposalApp)
+ .post(`/polls/not-an-id/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(proposalPayload()),
+ publicKey: AUTHOR,
+ network: "etherlink-mainnet",
+ })
+ .expect(404);
+ });
+
+ it("should return 400 for an invalid dao address", async () => {
+ const dao = await makeDao();
+ const poll = await makePoll(dao._id, { author: AUTHOR });
+
+ await request(linkProposalApp)
+ .post(`/polls/${poll._id}/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(proposalPayload({ daoAddress: "not-an-address" })),
+ publicKey: AUTHOR,
+ network: "etherlink-mainnet",
+ })
+ .expect(400);
+ });
+
+ it("should return 400 for a missing proposal key", async () => {
+ const dao = await makeDao();
+ const poll = await makePoll(dao._id, { author: AUTHOR });
+
+ await request(linkProposalApp)
+ .post(`/polls/${poll._id}/link-proposal`)
+ .send({
+ payloadBytes: signedPayload(proposalPayload({ proposalKey: " " })),
+ publicKey: AUTHOR,
+ network: "etherlink-mainnet",
+ })
+ .expect(400);
+ });
+ });
});
diff --git a/routes/subscriptions.js b/routes/subscriptions.js
new file mode 100644
index 0000000..140fee1
--- /dev/null
+++ b/routes/subscriptions.js
@@ -0,0 +1,86 @@
+const express = require("express");
+
+const {
+ createSubscription,
+ confirmSubscription,
+ unsubscribeSubscription,
+} = require("../components/subscriptions");
+
+// recordRoutes is an instance of the express router.
+// We use it to define our routes.
+const subscriptionsRoutes = express.Router();
+
+/**
+ * @swagger
+ * /subscriptions:
+ * post:
+ * summary: Subscribe an email address to alerts for a DAO
+ * tags: [Subscriptions]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [email, daoAddress, network]
+ * properties:
+ * email:
+ * type: string
+ * description: Email address to send alerts to
+ * daoAddress:
+ * type: string
+ * description: Tezos contract address of the DAO (KT1...)
+ * network:
+ * type: string
+ * description: Network the DAO lives on (mainnet, ghostnet, shadownet)
+ * responses:
+ * 200:
+ * description: Request accepted, a confirmation email was sent if needed
+ * 400:
+ * description: Invalid email, DAO address or network
+ * 429:
+ * description: Too many subscription requests for this email
+ */
+subscriptionsRoutes.route("/subscriptions").post(createSubscription);
+
+/**
+ * @swagger
+ * /subscriptions/confirm/{token}:
+ * get:
+ * summary: Confirm an email subscription
+ * tags: [Subscriptions]
+ * parameters:
+ * - in: path
+ * name: token
+ * required: true
+ * description: The confirmation token from the subscription email
+ * schema:
+ * type: string
+ * responses:
+ * 302:
+ * description: Redirect to the DAO page, or to the app root for an unknown token
+ */
+subscriptionsRoutes.route("/subscriptions/confirm/:token").get(confirmSubscription);
+
+/**
+ * @swagger
+ * /subscriptions/unsubscribe/{token}:
+ * get:
+ * summary: Remove an email subscription
+ * tags: [Subscriptions]
+ * parameters:
+ * - in: path
+ * name: token
+ * required: true
+ * description: The unsubscribe token from any alert email
+ * schema:
+ * type: string
+ * responses:
+ * 302:
+ * description: Redirect to the DAO page, or to the app root for an unknown token
+ */
+subscriptionsRoutes
+ .route("/subscriptions/unsubscribe/:token")
+ .get(unsubscribeSubscription);
+
+module.exports = subscriptionsRoutes;
diff --git a/routes/subscriptions.test.js b/routes/subscriptions.test.js
new file mode 100644
index 0000000..374e66a
--- /dev/null
+++ b/routes/subscriptions.test.js
@@ -0,0 +1,217 @@
+const request = require("supertest");
+const express = require("express");
+const mongoose = require("mongoose");
+
+jest.mock("../services/mailer.service", () => {
+ const actual = jest.requireActual("../services/mailer.service");
+ return {
+ ...actual,
+ sendMail: jest.fn().mockResolvedValue({ skipped: true }),
+ };
+});
+
+const { sendMail } = require("../services/mailer.service");
+const subscriptionsRoutes = require("./subscriptions");
+const { resetRateLimit } = require("../components/subscriptions");
+const SubscriptionModel = require("../db/models/Subscription.model");
+const { connectToMongoose } = require("../db/mongoose-connection");
+
+const app = express();
+app.use(express.json());
+app.use("/", subscriptionsRoutes);
+
+// A real KT1 contract address, needed because validateAddress checks the checksum.
+const DAO_ADDRESS = "KT1T17GC91HrJ8ijZgnMaE9j4PZbojbVAn73";
+const APP_URL = "https://tezos-homebase.io";
+const API_URL = "https://homebase-backend.netlify.app";
+
+describe("Subscriptions Routes", () => {
+ beforeAll(async () => {
+ await connectToMongoose();
+ });
+
+ beforeEach(async () => {
+ await SubscriptionModel.deleteMany({});
+ resetRateLimit();
+ sendMail.mockClear();
+ });
+
+ afterAll(async () => {
+ if (mongoose.connection.readyState !== 0) {
+ await mongoose.connection.close();
+ }
+ });
+
+ describe("POST /subscriptions", () => {
+ it("should create an unconfirmed subscription and send a confirmation email", async () => {
+ const response = await request(app)
+ .post("/subscriptions")
+ .send({ email: "Alerts.User+dao@Example.com", daoAddress: DAO_ADDRESS, network: "mainnet" })
+ .expect(200);
+
+ expect(response.body).toEqual({ ok: true });
+
+ const saved = await SubscriptionModel.findOne({ daoAddress: DAO_ADDRESS });
+ expect(saved).not.toBeNull();
+ expect(saved.email).toBe("alerts.user+dao@example.com");
+ expect(saved.network).toBe("mainnet");
+ expect(saved.confirmed).toBe(false);
+ expect(saved.confirmToken).toHaveLength(64);
+ expect(saved.unsubscribeToken).toHaveLength(64);
+
+ expect(sendMail).toHaveBeenCalledTimes(1);
+ const mail = sendMail.mock.calls[0][0];
+ expect(mail.to).toBe("alerts.user+dao@example.com");
+ expect(mail.text).toContain(`${API_URL}/subscriptions/confirm/${saved.confirmToken}`);
+ expect(mail.text).toContain(DAO_ADDRESS);
+ expect(mail.text).toContain("mainnet");
+ expect(mail.text).toContain(
+ `${API_URL}/subscriptions/unsubscribe/${saved.unsubscribeToken}`
+ );
+ expect(mail.headers["List-Unsubscribe"]).toBe(
+ `<${API_URL}/subscriptions/unsubscribe/${saved.unsubscribeToken}>`
+ );
+ });
+
+ it("should accept shadownet as a network", async () => {
+ await request(app)
+ .post("/subscriptions")
+ .send({ email: "user@example.com", daoAddress: DAO_ADDRESS, network: "shadownet" })
+ .expect(200);
+
+ const saved = await SubscriptionModel.findOne({ network: "shadownet" });
+ expect(saved).not.toBeNull();
+ });
+
+ it("should not create a duplicate subscription for the same email, dao and network", async () => {
+ const body = { email: "user@example.com", daoAddress: DAO_ADDRESS, network: "mainnet" };
+
+ await request(app).post("/subscriptions").send(body).expect(200);
+ await request(app).post("/subscriptions").send(body).expect(200);
+
+ const count = await SubscriptionModel.countDocuments({ email: "user@example.com" });
+ expect(count).toBe(1);
+ });
+
+ it("should not resend a confirmation email for an already confirmed subscription", async () => {
+ await SubscriptionModel.create({
+ email: "user@example.com",
+ daoAddress: DAO_ADDRESS,
+ network: "mainnet",
+ confirmed: true,
+ confirmToken: "a".repeat(64),
+ unsubscribeToken: "b".repeat(64),
+ confirmedAt: new Date(),
+ });
+
+ const response = await request(app)
+ .post("/subscriptions")
+ .send({ email: "user@example.com", daoAddress: DAO_ADDRESS, network: "mainnet" })
+ .expect(200);
+
+ expect(response.body).toEqual({ ok: true });
+ expect(sendMail).not.toHaveBeenCalled();
+ });
+
+ it("should reject an invalid email address", async () => {
+ await request(app)
+ .post("/subscriptions")
+ .send({ email: "not-an-email", daoAddress: DAO_ADDRESS, network: "mainnet" })
+ .expect(400);
+
+ expect(sendMail).not.toHaveBeenCalled();
+ });
+
+ it("should reject a non KT1 dao address", async () => {
+ await request(app)
+ .post("/subscriptions")
+ .send({
+ email: "user@example.com",
+ daoAddress: "tz1burnburnburnburnburnburnburjAYjjX",
+ network: "mainnet",
+ })
+ .expect(400);
+ });
+
+ it("should reject an unsupported network", async () => {
+ await request(app)
+ .post("/subscriptions")
+ .send({ email: "user@example.com", daoAddress: DAO_ADDRESS, network: "notanetwork" })
+ .expect(400);
+ });
+
+ it("should reject more than 5 requests per email per hour", async () => {
+ const body = { email: "spammer@example.com", daoAddress: DAO_ADDRESS, network: "mainnet" };
+
+ for (let i = 0; i < 5; i += 1) {
+ await request(app).post("/subscriptions").send(body).expect(200);
+ }
+
+ await request(app).post("/subscriptions").send(body).expect(429);
+ });
+ });
+
+ describe("GET /subscriptions/confirm/:token", () => {
+ it("should confirm the subscription and redirect to the dao page", async () => {
+ const subscription = await SubscriptionModel.create({
+ email: "user@example.com",
+ daoAddress: DAO_ADDRESS,
+ network: "mainnet",
+ confirmToken: "c".repeat(64),
+ unsubscribeToken: "d".repeat(64),
+ });
+
+ const response = await request(app)
+ .get(`/subscriptions/confirm/${subscription.confirmToken}`)
+ .expect(302);
+
+ expect(response.headers.location).toBe(
+ `${APP_URL}/explorer/dao/${DAO_ADDRESS}?alerts=confirmed`
+ );
+
+ const saved = await SubscriptionModel.findById(subscription._id);
+ expect(saved.confirmed).toBe(true);
+ expect(saved.confirmedAt).toBeInstanceOf(Date);
+ });
+
+ it("should redirect to the app root for an unknown token", async () => {
+ const response = await request(app)
+ .get(`/subscriptions/confirm/${"e".repeat(64)}`)
+ .expect(302);
+
+ expect(response.headers.location).toBe(`${APP_URL}/explorer/daos?alerts=invalid`);
+ });
+ });
+
+ describe("GET /subscriptions/unsubscribe/:token", () => {
+ it("should delete the subscription and redirect to the dao page", async () => {
+ const subscription = await SubscriptionModel.create({
+ email: "user@example.com",
+ daoAddress: DAO_ADDRESS,
+ network: "mainnet",
+ confirmed: true,
+ confirmToken: "f".repeat(64),
+ unsubscribeToken: "1".repeat(64),
+ });
+
+ const response = await request(app)
+ .get(`/subscriptions/unsubscribe/${subscription.unsubscribeToken}`)
+ .expect(302);
+
+ expect(response.headers.location).toBe(
+ `${APP_URL}/explorer/dao/${DAO_ADDRESS}?alerts=unsubscribed`
+ );
+
+ const saved = await SubscriptionModel.findById(subscription._id);
+ expect(saved).toBeNull();
+ });
+
+ it("should redirect to the app root for an unknown token", async () => {
+ const response = await request(app)
+ .get(`/subscriptions/unsubscribe/${"2".repeat(64)}`)
+ .expect(302);
+
+ expect(response.headers.location).toBe(`${APP_URL}/explorer/daos?alerts=invalid`);
+ });
+ });
+});
diff --git a/server.js b/server.js
index c1c100e..f6f524d 100644
--- a/server.js
+++ b/server.js
@@ -54,6 +54,7 @@ app.use(require("./routes/tokens"));
app.use(require("./routes/choices"));
app.use(require("./routes/blocks"));
app.use(require("./routes/aci"));
+app.use(require("./routes/subscriptions"));
// Global error handler to avoid crashing without logs
// Place after routes to catch any unhandled errors
diff --git a/services/mailer.service.js b/services/mailer.service.js
new file mode 100644
index 0000000..faf9ec9
--- /dev/null
+++ b/services/mailer.service.js
@@ -0,0 +1,85 @@
+const nodemailer = require("nodemailer");
+
+const DEFAULT_MAIL_FROM = "Homebase Alerts ";
+
+let cachedTransport = null;
+
+/**
+ * Public URLs used to build links inside outgoing mail.
+ * APP_URL points at the frontend, PUBLIC_API_URL at this backend.
+ */
+const getAppUrl = () =>
+ (process.env.APP_URL || "https://tezos-homebase.io").replace(/\/+$/, "");
+
+const getPublicApiUrl = () =>
+ (process.env.PUBLIC_API_URL || "https://homebase-backend.netlify.app").replace(
+ /\/+$/,
+ ""
+ );
+
+const isMailEnabled = () => Boolean(process.env.SMTP_HOST);
+
+const getTransport = () => {
+ if (cachedTransport) return cachedTransport;
+
+ const port = Number(process.env.SMTP_PORT || 587);
+ const options = {
+ host: process.env.SMTP_HOST,
+ port,
+ // Port 465 is implicit TLS, everything else uses STARTTLS.
+ secure: port === 465,
+ };
+
+ if (process.env.SMTP_USER || process.env.SMTP_PASS) {
+ options.auth = {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS,
+ };
+ }
+
+ cachedTransport = nodemailer.createTransport(options);
+ return cachedTransport;
+};
+
+/**
+ * Send one email. When SMTP_HOST is not configured this is a no-op so that
+ * local development and the test suite never try to open a real connection.
+ *
+ * @param {object} params
+ * @param {string} params.to Recipient address
+ * @param {string} params.subject Subject line
+ * @param {string} [params.text] Plain text body
+ * @param {string} [params.html] HTML body
+ * @param {object} [params.headers] Extra headers, e.g. List-Unsubscribe
+ * @returns {Promise<{skipped: boolean, messageId?: string, error?: string}>}
+ */
+const sendMail = async ({ to, subject, text, html, headers }) => {
+ if (!isMailEnabled()) {
+ console.log("[mailer:skipped]", { to, subject, reason: "SMTP_HOST is not set" });
+ return { skipped: true };
+ }
+
+ try {
+ const info = await getTransport().sendMail({
+ from: process.env.MAIL_FROM || DEFAULT_MAIL_FROM,
+ to,
+ subject,
+ text,
+ html,
+ headers,
+ });
+ console.log("[mailer:sent]", { to, subject, messageId: info?.messageId });
+ return { skipped: false, messageId: info?.messageId };
+ } catch (error) {
+ // Mail delivery must never break the request that triggered it.
+ console.error("[mailer:error]", { to, subject, error: error?.message });
+ return { skipped: false, error: error?.message };
+ }
+};
+
+module.exports = {
+ sendMail,
+ isMailEnabled,
+ getAppUrl,
+ getPublicApiUrl,
+};