SNS.yaml: mapping to CreateServiceNervousSystem

Follow-up to this post.

I am having trouble finding where within DFINITY’s code base the SNS.yaml data are mapped to the CreateServiceNervousSystem struct, which is expected by the Governance canister to create the related proposal.

Can you point me to the mapper?

That’s my Zod parser to load properly the Yaml file:

import { DEV } from '$lib/constants/app.constants';
import { Principal } from '@dfinity/principal';
import { isNullish } from '@dfinity/utils';
import { z } from 'zod';

const assertBytes = ({ text, min, max }: { text: string; min: number; max: number }): boolean => {
	const byteLength = new TextEncoder().encode(text).length;
	return byteLength >= min && byteLength <= max;
};

const assertValue = ({ value: text, labels }: { value: string; labels: string[] }): boolean => {
	if (text.trim().length !== text.length) {
		return false;
	}

	const [value, symbol] = text.split(' ');

	if (isNullish(value) || isNullish(symbol)) {
		return false;
	}

	const number = parseFloat(value.replace(/_/g, ''));
	const isNumber = !isNaN(number) && isFinite(number);

	return isNumber && labels.includes(symbol);
};

const urlSchema = z
	.string()
	.url()
	.refine((url: string): boolean => assertBytes({ text: url, min: 10, max: 512 }), {
		message: 'URL must be between 10 to 512 bytes'
	});

const titleSchema = z
	.string()
	.refine((text: string): boolean => assertBytes({ text, min: 4, max: 256 }), {
		message: 'Title must be between 4 to 256 bytes'
	});

const summarySchema = z
	.string()
	.refine((text: string): boolean => assertBytes({ text, min: 10, max: 2000 }), {
		message: 'Summary must be between 10 to 2000 bytes'
	});

const tokenNameSchema = z
	.string()
	.refine((text: string): boolean => assertBytes({ text, min: 4, max: 255 }), {
		message: 'Title must be between 4 to 255 bytes'
	});

const nnsProposalSchema = z.object({
	title: titleSchema,
	url: z.string().url(),
	summary: summarySchema
});

const symbolSchema = z
	.string()
	.refine((symbol: string): boolean => symbol.trim().length === symbol.length, {
		message: 'Symbol must not have leading or trailing spaces'
	})
	.refine((text: string): boolean => assertBytes({ text, min: 3, max: 10 }), {
		message: 'Symbol must be between 3 to 10 bytes'
	});

const e8sSchema = z.string().refine(
	(e8s: string): boolean =>
		assertValue({
			value: e8s,
			labels: ['e8s']
		}),
	{
		message: "Value must contain a number and end with 'e8s'"
	}
);

const tokenSchema = z.object({
	name: tokenNameSchema,
	symbol: symbolSchema,
	transaction_fee: e8sSchema,
	logo: z.string().optional()
});

const tokenValueSchema = z.string().refine(
	(token: string): boolean =>
		assertValue({
			value: token,
			labels: ['token', 'tokens']
		}),
	{
		message: "Value must contain a number and end with 'token' or 'tokens'"
	}
);

const durationSchema = z.string().refine(
	(duration: string): boolean => {
		const durationSuffixes = [
			'seconds',
			'second',
			'sec',
			's',
			'minutes',
			'minute',
			'min',
			'm',
			'hours',
			'hour',
			'hr',
			'h',
			'days',
			'day',
			'd',
			'weeks',
			'week',
			'w',
			'months',
			'month',
			'M',
			'years',
			'year',
			'y'
		];
		const durationPattern = new RegExp(`^\\s*(\\d+\\s*(${durationSuffixes.join('|')})\\s*)+$`);
		return durationPattern.test(duration.trim());
	},
	{
		message: "Value must be a valid duration string (e.g., '1w 2d 3h')."
	}
);

const proposalsSchema = z.object({
	rejection_fee: tokenValueSchema,
	initial_voting_period: durationSchema,
	maximum_wait_for_quiet_deadline_extension: durationSchema
});

const principalIdSchema = z.string().refine(
	(id: string) => {
		try {
			Principal.fromText(id);
			return true;
		} catch {
			return false;
		}
	},
	{
		message: 'Invalid PrincipalId'
	}
);

const neuronsSchema = z.object({
	minimum_creation_stake: tokenValueSchema
});

const percentageSchema = z.string().refine(
	(percentage: string): boolean => {
		if (percentage.trim().length !== percentage.length) {
			return false;
		}

		return percentage.endsWith('%') && !isNaN(parseInt(percentage.slice(0, -1)));
	},
	{
		message: "Value must be a valid percentage string (e.g., '10%')."
	}
);

const votingSchema = z.object({
	minimum_dissolve_delay: durationSchema,
	MaximumVotingPowerBonuses: z.object({
		DissolveDelay: z.object({
			duration: durationSchema,
			bonus: percentageSchema
		}),
		Age: z.object({
			duration: durationSchema,
			bonus: percentageSchema
		})
	}),
	RewardRate: z.object({
		initial: percentageSchema,
		final: percentageSchema,
		transition_duration: durationSchema
	})
});

const neuronSchema = z.object({
	principal: principalIdSchema,
	stake: tokenValueSchema,
	memo: z.number(),
	dissolve_delay: durationSchema,
	vesting_period: durationSchema
});

export type NeuronSchema = z.infer<typeof neuronSchema>;

const distributionSchema = z.object({
	Neurons: neuronSchema.array(),
	InitialBalances: z.object({
		governance: tokenValueSchema,
		swap: tokenValueSchema
	}),
	total: tokenValueSchema
});

const confirmationSchema = z
	.string()
	.refine(
		(text: string): boolean =>
			text.length >= 1 && text.length <= 1000 && assertBytes({ text, min: 0, max: 8000 }),
		{
			message: 'Confirmation must be between 1 to 1,000 characters and at most 8,000 bytes'
		}
	);

const countryCodeSchema = z
	.string()
	.length(2, 'Each country code must be exactly 2 characters long')
	.refine((code) => code === code.toUpperCase(), {
		message: 'Each country code must be in uppercase'
	});

const timeOfDaySchema = z
	.string()
	.refine((time: string): boolean => /^([01]\d|2[0-3]):([0-5]\d) UTC$/.test(time.trim()), {
		message: "Value must be a valid time of day string in the form 'hh:mm UTC'."
	});

const swapSchema = z.object({
	minimum_participants: z.number(),
	minimum_direct_participation_icp: tokenValueSchema,
	maximum_direct_participation_icp: tokenValueSchema,
	minimum_participant_icp: tokenValueSchema,
	maximum_participant_icp: tokenValueSchema,
	confirmation_text: confirmationSchema.optional(),
	restricted_countries: countryCodeSchema.array().optional(),
	VestingSchedule: z.object({
		events: z.number().min(2),
		interval: durationSchema
	}),
	start_time: DEV ? timeOfDaySchema.optional() : timeOfDaySchema,
	duration: durationSchema,
	neurons_fund_participation: z.boolean()
});

export const snsYaml = z.object({
	name: z.string().max(255),
	description: z.string().max(2000),
	Principals: z.string().array().length(0),
	logo: z.string().optional(),
	url: urlSchema,
	NnsProposal: nnsProposalSchema,
	fallback_controller_principals: z.array(principalIdSchema).min(1),
	dapp_canisters: z.array(principalIdSchema),
	Token: tokenSchema,
	Proposals: proposalsSchema,
	Neurons: neuronsSchema,
	Voting: votingSchema,
	Distribution: distributionSchema,
	Swap: swapSchema
});

export type SnsYaml = z.infer<typeof snsYaml>;

and that’s my mapper:

import { type NeuronSchema, type SnsYaml } from '$lib/types/sns';
import type { CreateServiceNervousSystem, Tokens } from '@dfinity/nns';
import type {
	Duration,
	GlobalTimeOfDay,
	NeuronDistribution,
	Percentage
} from '@dfinity/nns/dist/types/types/governance_converters';
import { isNullish, nonNullish } from '@dfinity/utils';

const mapTokens = (value: string): Tokens => ({
	e8s: BigInt(
		value
			.toLowerCase()
			.replace('e8s', '')
			.replace('tokens', '')
			.replace('token', '')
			.replaceAll('_', '')
			.trim()
	)
});

const mapPercentage = (percentage: string): Percentage => ({
	basisPoints: BigInt(Number(percentage.toLowerCase().replace('%', '').trim()) * 100)
});

const mapDuration = (duration: string): Duration => {
	// DFINITY uses humantime crate
	// Source: https://github.com/dfinity/ic/blob/17df8febdb922c3981475035d830f09d9b990a5a/rs/nervous_system/humanize/src/lib.rs#L58
	// Crate: https://github.com/tailhook/humantime/blob/12ce6f50894a56a410b390e5608ac9db8afe2407/src/duration.rs#L123
	const unitsToSeconds: Record<string, number> = {
		seconds: 1,
		second: 1,
		sec: 1,
		s: 1,
		minutes: 60,
		minute: 60,
		min: 60,
		m: 60,
		hours: 3600,
		hour: 3600,
		hr: 3600,
		h: 3600,
		days: 86400,
		day: 86400,
		d: 86400,
		weeks: 604800,
		week: 604800,
		w: 604800,
		months: 2630016, // 30.44 days
		month: 2630016,
		M: 2630016,
		years: 31557600, // 365.25 days
		year: 31557600,
		y: 31557600
	};

	let totalSeconds = 0;

	const durationParts = duration.match(
		/\d+\s*(seconds?|sec|s|minutes?|min|m|hours?|hr|h|days?|d|weeks?|w|months?|M|years?|y)/g
	);

	if (isNullish(durationParts)) {
		throw new Error(`Invalid duration string: ${duration}`);
	}

	durationParts.forEach((part) => {
		const matches = part.match(/\d+|\D+/g);

		if (isNullish(matches) || matches.length !== 2) {
			throw new Error(`Invalid duration part: ${duration} - ${part}`);
		}

		const [value, unit] = matches;
		totalSeconds += parseInt(value) * (unitsToSeconds[unit.trim().toLowerCase()] ?? 0);
	});

	return {
		seconds: BigInt(totalSeconds)
	};
};

const mapTimeOfDay = (timeOfDay: string): GlobalTimeOfDay => {
	const [hours, minutes] = timeOfDay.split(' ')[0].split(':').map(Number);

	return {
		secondsAfterUtcMidnight: BigInt(hours * 3600 + minutes * 60)
	};
};

const mapNeuron = ({
	principal,
	memo,
	stake,
	dissolve_delay,
	vesting_period
}: NeuronSchema): NeuronDistribution => ({
	controller: principal,
	memo: BigInt(memo),
	stake: mapTokens(stake),
	dissolveDelay: mapDuration(dissolve_delay),
	vestingPeriod: mapDuration(vesting_period)
});

// Map source: https://github.com/dfinity/ic/blob/17df8febdb922c3981475035d830f09d9b990a5a/rs/registry/admin/src/main.rs#L2592
export const mapSnsYamlToCreateServiceNervousSystem = ({
	yaml: {
		name,
		description,
		url,
		Token,
		Voting,
		Proposals,
		Neurons,
		fallback_controller_principals: fallbackControllerPrincipalIds,
		dapp_canisters: dappCanisters,
		Swap,
		Distribution
	},
	logo
}: {
	yaml: SnsYaml;
	logo: string;
}): CreateServiceNervousSystem => ({
	name,
	url,
	description,
	logo: {
		base64Encoding: logo
	},
	ledgerParameters: {
		transactionFee: mapTokens(Token.transaction_fee),
		tokenSymbol: Token.symbol,
		tokenLogo: {
			base64Encoding: logo
		},
		tokenName: Token.name
	},
	governanceParameters: {
		neuronMaximumDissolveDelayBonus: mapPercentage(
			Voting.MaximumVotingPowerBonuses.DissolveDelay.bonus
		),
		neuronMaximumAgeForAgeBonus: mapDuration(Voting.MaximumVotingPowerBonuses.Age.duration),
		neuronMaximumDissolveDelay: mapDuration(
			Voting.MaximumVotingPowerBonuses.DissolveDelay.duration
		),
		neuronMinimumDissolveDelayToVote: mapDuration(Voting.minimum_dissolve_delay),
		neuronMaximumAgeBonus: mapPercentage(Voting.MaximumVotingPowerBonuses.Age.bonus),
		neuronMinimumStake: mapTokens(Neurons.minimum_creation_stake),
		proposalWaitForQuietDeadlineIncrease: mapDuration(
			Proposals.maximum_wait_for_quiet_deadline_extension
		),
		proposalInitialVotingPeriod: mapDuration(Proposals.initial_voting_period),
		proposalRejectionFee: mapTokens(Proposals.rejection_fee),
		votingRewardParameters: {
			rewardRateTransitionDuration: mapDuration(Voting.RewardRate.transition_duration),
			initialRewardRate: mapPercentage(Voting.RewardRate.initial),
			finalRewardRate: mapPercentage(Voting.RewardRate.final)
		}
	},
	fallbackControllerPrincipalIds,
	dappCanisters,
	swapParameters: {
		minimumParticipants: BigInt(Swap.minimum_participants),
		duration: mapDuration(Swap.duration),
		neuronBasketConstructionParameters: {
			count: BigInt(Swap.VestingSchedule.events),
			dissolveDelayInterval: mapDuration(Swap.VestingSchedule.interval)
		},
		confirmationText: Swap.confirmation_text,
		maximumParticipantIcp: mapTokens(Swap.maximum_participant_icp),
		neuronsFundInvestmentIcp: undefined,
		minimumIcp: undefined,
		minimumParticipantIcp: mapTokens(Swap.minimum_participant_icp),
		startTime: nonNullish(Swap.start_time) ? mapTimeOfDay(Swap.start_time) : undefined,
		maximumIcp: undefined,
		restrictedCountries: nonNullish(Swap.restricted_countries)
			? {
					isoCodes: Swap.restricted_countries
				}
			: undefined,
		maxDirectParticipationIcp: mapTokens(Swap.maximum_direct_participation_icp),
		minDirectParticipationIcp: mapTokens(Swap.minimum_direct_participation_icp),
		neuronsFundParticipation: Swap.neurons_fund_participation
	},
	initialTokenDistribution: {
		swapDistribution: {
			total: mapTokens(Distribution.InitialBalances.swap)
		},
		treasuryDistribution: {
			total: mapTokens(Distribution.InitialBalances.governance)
		},
		developerDistribution: {
			developerNeurons: Distribution.Neurons.map(mapNeuron)
		}
	}
});

But currently I’m facing the error Invalid CreateServiceNervousSystem: Error: neuron_minimum_stake_e8s=100 is too small. It needs to be greater than the transaction fee (1000000 e8s).

I’m using the WaterNeuron Yaml file so I’m guessing that some maths are missing, that’s why I would really help to know where the mapping is done and how.

That is implemented in try_convert_to_create_service_nervous_system. Although this works with an already parsed-and-normalized yaml file. Generally the conversion is very straightforward, but users are also allowed to use human-friendly units such as 5 tokens which are parsed using code implemented here.

Please let me know if you would like more help on the mapping code.

1 Like

Thanks a lot for the really useful links!!! I’ll go through and will compare with my initial implementation.

At the moment my biggest issue is the conversion of seconds for human readable months and years: https://forum.dfinity.org/t/sns-yaml-month-and-year-conversion-to-seconds/32905/2

No problem! I responded on that thread

So, I just mapped the fields myself using my best guess.

Then I used existing SNS.yaml files shared by the project, ran those locally with minor tweaks, and compared the outcome of submitting the proposal locally to what was executed on mainnet until both looked similar.