Pull to refresh
0

Игра “Атака 51%”: пишем простую автономную игру на платформе Obyte

Reading time12 min
Views1.8K


В прошлой статье «Автономные Агенты» или исполняем код в открытой криптоплатформе Obyte мы рассказывали о том что такое Автономные Агенты и сравнивали их со смарт-контрактами Ethereum. Давайте теперь напишем нашего первого Автономного Агента (АА) на примере игры «Атака 51%». А в конце статьи разберём способы улучшения его: как обезопасить игроков от проигрыша / потери средств и как улучшить алгоритм для уменьшения влияния «китов» с большими депозитами на исход игры.

Оригиналы обоих автономных агентов всегда доступны в онлайн редакторе кода Oscript в виде шаблонов, достаточно выбрать их из выпадающего меню: «51% attack game» и «Fundraising proxy».

Прежде чем начать писать АА на языке Oscript, настоятельно рекомендую прочитать Getting Started Guide (eng) в нашей документации, чтобы быстро ознакомиться с основными принципами написания АА.

Суть игры


Несколько команд одновременно соревнуются между собой за право забрать весь пул собранных средств. Игроки команды делают вклады, тем самым увеличивая общую сумму пула. Выигрывает команда, сделавшая вкладов как минимум на 51% от всех собранных средств и продержавшаяся в роли лидера не менее суток. Между игроками победившей команды пул распределяется пропорционально сделанному вкладу, тем самым каждый её участник может потенциально удвоить своё вложение.

Как только какая либо из команд собирает >= 51% от всех средств, она предварительно объявляется победителем и её участники больше не могут делать вклады. Зато у всех остальных команд есть 24 часа на то, чтобы обогнать команду-победителя. Как только это происходит, обогнавшая команда теперь становится победителем и таймер начинает отсчёт заново.

Наша реализация будет выдавать «акции» каждому вкладчику взамен на байты, в пропорции 1-к-1, которые, в случае выигрыша команды, участник может обменять на долю в выигранном пуле. Акции представляют из себя asset на платформе Obyte, выпущенный специально для каждой команды. Их, как и любой asset, можно передавать / торговать, продавая свой потенциальный выигрыш другим людям. Цена акций будет зависеть от оценки рынком шансов команды на победу.

Любой желающий может создать команду. Создатель команды может установить комиссию с выигрыша, которая будет взиматься с каждого участника команды в случае победы.

Мы также применим в нашей игре второй АА, от лица создателя команды, который будет «краудфандить» необходимую сумму, и только в случае её достижения (в нашем примере, при достижении >51% от пула игры) будет отправлять все собранные средства на адрес АА игры, в ином же случае деньги можно будет свободно забрать обратно.

Пишем код на Oscript


Напомню, что код АА вызывается каждый раз, когда на адрес этого АА приходит любая транзакция, т.н. триггер-транзакция. Собственно АА представляет из себя код, который в ответ на input (данные в триггер-транзакции и текущее состояние самого АА, хранимое в state переменных) генерирует output (ещё одну «ответную» транзакцию), либо меняет свой state. Наша задача – запрограммировать правила реакции АА на входные данные. Любые транзакции в Obyte представляют из себя набор сообщений, чаще всего это «payment» сообщения, либо же «data» сообщения, и др.
 

Инициализация


Для начала проинициализируем наш АА. Блок init вызывается при каждом запуске АА, в самом его начале. В нём мы установим локальные константы для более удобного обращения к значениям.
 
{
    init: `{
        $team_creation_fee = 5000;
        $challenging_period = 24*3600;
        $bFinished = var['finished'];
    }`,
    messages: {
		cases: [
		]
    }
}

Строка $bFinished = var['finished']; читает переменную finished из стейта АА.
Массив результирующих сообщений у нас будет обрамлён блоком cases:{}, что аналогично привычному нам switch/case/default, на основе условий в дочерних блоках if, будет выбрано только одно из сообщений, либо, если ни один блок if не вернул true, будет выбрано последнее сообщение.
 

Создаём команду


Итак, первый блок будет обрабатывать транзакции на создание новой команды:
 
// create a new team; any excess amount is sent back
if: `{trigger.data.create_team AND !$bFinished}`,
init: `{
	if (var['team_' || trigger.address || '_asset'])
		bounce('you already have a team');
	if (trigger.output[[asset=base]] < $team_creation_fee)
		bounce('not enough to pay for team creation');
}`,
messages: [
	{
		app: 'asset',
		payload: {
			is_private: false,
			is_transferrable: true,
			auto_destroy: false,
			fixed_denominations: false,
			issued_by_definer_only: true,
			cosigned_by_definer: false,
			spender_attested: false
		}
	},
	{
		app: 'payment',
		if: `{trigger.output[[asset=base]] > $team_creation_fee}`,
		payload: {
			asset: 'base',
			outputs: [
				{address: "{trigger.address}", amount: "{trigger.output[[asset=base]] - $team_creation_fee}"}
			]
		}
	},
	{
		app: 'state',
		state: `{
			var['team_' || trigger.address || '_founder_tax'] = trigger.data.founder_tax otherwise 0;
			var['team_' || trigger.address || '_asset'] = response_unit;
			response['team_asset'] = response_unit;
		}`
	}
]

Как мы видим, условие для исполнения этого блока у нас идёт самым первым в блоке if и представляет из себя проверку, что в триггер-транзакции содержится сообщение с типом data (trigger.data.create_team), в котором имеется ключ с названием create_team и что игра ещё не завершена (!$bFinished). Локальная константа $bFinished доступна из любого места кода, если она была проинициализирована в блоке init. Если бы хоть какое-то из этих условий не выполнилось, то родительский блок cases просто бы продолжил выполнение и проверку условий для следующих сообщений, пропустив данное.
 
В следующем блоке init мы ничего не инициализируем, но зато проводим проверку необходимых условий, без которых триггер-транзакция считается ошибочной:

if (var['team_' || trigger.address || '_asset'])
	bounce('you already have a team');
if (trigger.output[[asset=base]] < $team_creation_fee)
	bounce('not enough to pay for team creation');

Здесь мы конкатенируем (с помощью ||) строки с переменной из триггер транзакции и пытаемся узнать имеется ли переменная с именем 'team_' || trigger.address || '_asset' в стейте нашего АА. 

Вызов bounce() откатывает любые изменения, сделанные до текущего момента и возвращает ошибку вызывающему.

Также обратите внимание как осуществляется поиск внутри триггер-транзакции: trigger.output[[asset=base]] ищет output с asset==base, что вернёт сумму в байтах (base asset = bytes), которая была указана в триггер-транзакции. И если данной суммы недостаточно для создания новой команды, мы вызываем bounce(), молча съедая все пришедшие байты за вычетом bounce_fee, которая по умолчанию равна 10000 байт.

Далее начинается основная часть кода по созданию команды. Кратко, алгоритм следующий:

  1. Первое сообщение выпускает новый asset (app: 'asset')
  2. Второе сообщение возвращает всё, что больше необходимого количества байт на создание команды (app: 'payment'). Обратите внимание на блок if здесь. Если это условие будет ложно (создатель прислал ровно необходимое количество байт), то данное сообщение не будет включено в результирующую транзакцию, а просто будет выкинуто.
  3. Третье сообщение меняет state нашего АА (app: 'state'): записываем переданный в качестве аргумента founder_tax, либо выставляем его в 0, если в тригер-транзакции его не передали. Конструкция var1 otherwise var2 возвращает значение var1, если оно кастится к true, иначе вернёт var2. Тут же мы встречаем переменную response_unit, которая всегда содержит в себе хеш результирующего юнита. В данном случае, т.к. результирующий юнит будет создавать новый asset, названием asset-a и будет являться хеш создающего юнита. Строка response['team_asset'] = response_unit просто запишет этот же хеш (или asset для данной команды) в массив responseVars в итоговый юнит. Массив response также может быть прочитан тому, кто сделал триггер-транзацию, а также в эвент листенерах, подписанных на события с данным АА.

Принимаем вклады


С созданием команды закончили, переходим к следующему блоку – обработке депозитов от участников команд.
 
// contribute to a team
if: `{trigger.data.team AND !$bFinished}`,
init: `{
	if (!var['team_' || trigger.data.team || '_asset'])
		bounce('no such team');
	if (var['winner'] AND var['winner'] == trigger.data.team)
		bounce('contributions to candidate winner team are not allowed');
}`,
messages: [
	{
		app: 'payment',
		payload: {
			asset: `{var['team_' || trigger.data.team || '_asset']}`,
			outputs: [
				{address: "{trigger.address}", amount: "{trigger.output[[asset=base]]}"}
			]
		}
	},
	{
		app: 'state',
		state: `{
			var['team_' || trigger.data.team || '_amount'] += trigger.output[[asset=base]];
			if (var['team_' || trigger.data.team || '_amount'] > balance[base]*0.51){
				var['winner'] = trigger.data.team;
				var['challenging_period_start_ts'] = timestamp;
			}
		}`
	}
]

Из нового, что мы здесь встречаем – это выдача токенов asset-а выбранной команды его участнику взамен на его депозит в байтах: 

asset: `{var['team_' || trigger.data.team || '_asset']}`,
outputs: [{address: "{trigger.address}", amount: "{trigger.output[[asset=base]]}"}

Как мы помним, state-переменную 'team_' || trigger.data.team || '_asset' мы сохраняли на этапе создания команды, и в ней хранится хеш юнита, в котором мы создавали asset для данной команды, то есть название данного asset-a.
 
В этом же блоке и происходит проверка главного условия на 51%:

if (var['team_' || trigger.data.team || '_amount'] > balance[base]*0.51){
	var['winner'] = trigger.data.team;
	var['challenging_period_start_ts'] = timestamp;
}

Если после этой триггер-транзакции баланс указанной команды превысит 51%, то команда объявляется победителем и мы записываем текущий unix timestamp (запускаем таймер).
 
Этот timestamp будет проверяться в третьем блоке, когда мы получим триггер-транзакцию с попыткой завершения игры:

// finish the challenging period and set the winner
if: `{trigger.data.finish AND !$bFinished}`,
init: `{
	if (!var['winner'])
		bounce('no candidate winner yet');
	if (timestamp < var['challenging_period_start_ts'] + $challenging_period)
		bounce('challenging period not expired yet');
}`,
messages: [
	{
		app: 'state',
		state: `{
			var['finished'] = 1;
			var['total'] = balance[base];
			var['challenging_period_start_ts'] = false;
			response['winner'] = var['winner'];
		}`
	}
]

Выплачиваем приз


И заключительный блок, самый приятный, – выплата победителям всего депозита:

// pay out the winnings
if: `{
	if (!$bFinished)
		return false;
	$winner = var['winner'];
	$winner_asset = var['team_' || $winner || '_asset'];
	$asset_amount = trigger.output[[asset=$winner_asset]];
	$asset_amount > 0
}`,
init: `{
	$share = $asset_amount / var['team_' || $winner || '_amount'];
	$founder_tax = var['team_' || $winner || '_founder_tax'];
	$amount = round(( $share * (1-$founder_tax) + (trigger.address == $winner AND !var['founder_tax_paid'] ? $founder_tax : 0) ) * var['total']);
}`,
messages: [
	{
		app: 'payment',
		payload: {
			asset: "base",
			outputs: [
				{address: "{trigger.address}", amount: "{$amount}"}
			]
		}
	},
	{
		app: 'state',
		state: `{
			if (trigger.address == $winner)
				var['founder_tax_paid'] = 1;
		}`
	}
]

Здесь интересен блок инициализации, в котором мы заранее высчитываем необходимые значения:

 $share = $asset_amount / var['team_' || $winner || '_amount'];
$founder_tax = var['team_' || $winner || '_founder_tax'];
$amount = round(( $share * (1-$founder_tax) + (trigger.address == $winner AND !var['founder_tax_paid'] ? $founder_tax : 0) ) * var['total']);

Всем участникам команды, кроме её создателя, выплачивается сумма, пропорциональная их первоначальному взносу (1-к-1 байты в обмен на токены asset-a, присланного в триггер-транзакции). Создателю же выплачивается ещё и комиссия (проверяется, что адрес отправившего триггер-транзакцию равен адресу создателя победившей команды trigger.address == $winner). При этом важно не забывать, что комиссия должна быть выплачена только единожды, а создатель может присылать бесконечно много триггер-транзакций, поэтому мы сохраняем флаг в state АА.

Запускаем игру


Итак, код у нас готов. Приведём полный его листинг:

полный код АА
{
	init: `{
		$team_creation_fee = 5000;
		$challenging_period = 24*3600;
		$bFinished = var['finished'];
	}`,
	messages: {
		cases: [
			{ // create a new team; any excess amount is sent back
				if: `{trigger.data.create_team AND !$bFinished}`,
				init: `{
					if (var['team_' || trigger.address || '_amount'])
						bounce('you already have a team');
					if (trigger.output[[asset=base]] < $team_creation_fee)
						bounce('not enough to pay for team creation');
				}`,
				messages: [
					{
						app: 'asset',
						payload: {
							is_private: false,
							is_transferrable: true,
							auto_destroy: false,
							fixed_denominations: false,
							issued_by_definer_only: true,
							cosigned_by_definer: false,
							spender_attested: false
						}
					},
					{
						app: 'payment',
						if: `{trigger.output[[asset=base]] > $team_creation_fee}`,
						payload: {
							asset: 'base',
							outputs: [
								{address: "{trigger.address}", amount: "{trigger.output[[asset=base]] - $team_creation_fee}"}
							]
						}
					},
					{
						app: 'state',
						state: `{
							var['team_' || trigger.address || '_founder_tax'] = trigger.data.founder_tax otherwise 0;
							var['team_' || trigger.address || '_asset'] = response_unit;
							response['team_asset'] = response_unit;
						}`
					}
				]
			},
			{ // contribute to a team
				if: `{trigger.data.team AND !$bFinished}`,
				init: `{
					if (!var['team_' || trigger.data.team || '_asset'])
						bounce('no such team');
					if (var['winner'] AND var['winner'] == trigger.data.team)
						bounce('contributions to candidate winner team are not allowed');
				}`,
				messages: [
					{
						app: 'payment',
						payload: {
							asset: `{var['team_' || trigger.data.team || '_asset']}`,
							outputs: [
								{address: "{trigger.address}", amount: "{trigger.output[[asset=base]]}"}
							]
						}
					},
					{
						app: 'state',
						state: `{
							var['team_' || trigger.data.team || '_amount'] += trigger.output[[asset=base]];
							if (var['team_' || trigger.data.team || '_amount'] > balance[base]*0.51){
								var['winner'] = trigger.data.team;
								var['challenging_period_start_ts'] = timestamp;
							}
						}`
					}
				]
			},
			{ // finish the challenging period and set the winner
				if: `{trigger.data.finish AND !$bFinished}`,
				init: `{
					if (!var['winner'])
						bounce('no candidate winner yet');
					if (timestamp < var['challenging_period_start_ts'] + $challenging_period)
						bounce('challenging period not expired yet');
				}`,
				messages: [
					{
						app: 'state',
						state: `{
							var['finished'] = 1;
							var['total'] = balance[base];
							var['challenging_period_start_ts'] = false;
							response['winner'] = var['winner'];
						}`
					}
				]
			},
			{ // pay out the winnings
				if: `{
					if (!$bFinished)
						return false;
					$winner = var['winner'];
					$winner_asset = var['team_' || $winner || '_asset'];
					$asset_amount = trigger.output[[asset=$winner_asset]];
					$asset_amount > 0
				}`,
				init: `{
					$share = $asset_amount / var['team_' || $winner || '_amount'];
					$founder_tax = var['team_' || $winner || '_founder_tax'];
					$amount = round(( $share * (1-$founder_tax) + (trigger.address == $winner AND !var['founder_tax_paid'] ? $founder_tax : 0) ) * var['total']);
				}`,
				messages: [
					{
						app: 'payment',
						payload: {
							asset: "base",
							outputs: [
								{address: "{trigger.address}", amount: "{$amount}"}
							]
						}
					},
					{
						app: 'state',
						state: `{
							if (trigger.address == $winner)
								var['founder_tax_paid'] = 1;
						}`
					}
				]
			}
		]
	}
}


Давайте проверим код на валидность и попробуем его задеплоить в testnet.

  1. Переходим в онлайн редактор: https://testnet.oscript.org
  2. Вставляем наш код и нажимаем Validate. Если всё правильно, мы увидим расчёт сложности кода: AA validated, complexity = 27, ops = 176. Здесь ops – количество операций в нашем коде, complexity – сложность кода. В Obyte АА не имеют циклов, но даже это не позволяет на 100% обезопасить сеть от вредоносных АА с плохим кодом. Поэтому все АА имеют верхний предел сложности, complexity = 100. Подсчёт сложности кода происходит на моменте деплоя, при этом учитываются все ветки кода. Некоторые операции относительно лёгкие, такие как ± и др. Они не добавляют сложности. Другие же, такие как доступ к базе данных (модификация state) или сложные вычисления (вызов некоторых функций) добавляют сложности. Для уточнения, какие операции являются лёгкими, а какие сложными, – обратитесь к language reference.
  3. Нажимаем Deploy. Видим что-то похожее на

Check in explorer: https://testnetexplorer.obyte.org/#DiuxsmIijzkfAVgabS9chJm5Mflr74lZkTGud4PM1vI=
Agent address: 6R7SF6LTCNSPLYLIJWDF57BTQVZ7HT5N

Советую перейти по ссылке в explorer и убедиться, что юнит с нашим АА запостился в сеть. В explorer также виден полный код нашего АА, т.к. все АА в сети Obyte имеют открытый исходный код. Вы можете изучить код любого агента по его адресу.

Краудфандинг


А теперь к обещанным оптимизациям. В текущей реализации каждый игрок отправляет деньги сразу на адрес АА игры, указывая свою команду. При этом  команда может так никогда и не выбиться в лидеры, а деньги уже отправлены. Мы можем оптимизировать процесс сбора денег и избежать ситуации, что мы отправим деньги в команду, которая ни разу так и не станет победителем. Это можно сделать с помощью второго АА, устроить так называемый краудфандинг средств, установив динамический goal для суммы собранных средств равным 51% от суммы в игре.

В Oscript мы можем читать state любого другого АА, потому у нас и имеется возможность установить динамический goal в нашем краудфандинговом АА.

Алгоритм будет следующий: создатель команды просит игроков отправлять деньги не на адрес игры, а на адрес агента, реализующего функционал «краудфандинга». Этот агент же будет хранить собранную сумму байт у себя и отправит их в игру только если соберёт >= 51% от суммы в игре. И сразу данная команда становится лидером. Если же необходимая сумма не будет собрана, то деньги просто будут возвращены игрокам. На этапе сбора средств игроки будут получать не игровые токены команды, а краудфандинговые токены, которые в будущем можно рефандить или же обменять уже на игровые токены, в случае успеха.

В следующей статье мы реализуем данный функционал.

Собираем не байты, а аттестации


В простейшем виде игры «Атака 51%» речь идёт о суммах собранных средств. Таким образом, «киты» с большими кошельками могут забирать большинство выигрышей. 

Чтобы сделать игру более честной для всех участников, попробуем считать не суммы присланных байт, а количество участников в командах. Побеждает та команда, которая смогла привлечь максимальное количество участников. Каждый игрок будет вкладывать фиксированную сумму, допустим 1GB, и считаться за единицу в пуле команды. Но ничего не мешает нам создавать бесконечное множество новых адресов, поэтому критически важным условием будет, что в игре разрешено участвовать только адресам, которые привязаны к каким-то другим ID, в которых соблюдается правило “один человек — один ID”. Такая привязка называется аттестацией. Примером аттестации является прохождение KYC процедуры, после которой в DAG постится сообщение о связи между Obyte адресом и хэшем персональных данных (сами персональные данные сохраняются в кошельке пользователя, и он их может при желании раскрыть отдельным контрагентам, но для данной задачи они не нужны, важен лишь сам факт привязки). Другой пример аттестации — аттестация email на доменах, где соблюдается правило “один человек — один email”, такое практикуется, например, на доменах некоторых университетов, компаний и стран. Таким образом один реальный человек будет посчитан только один раз.

Автономные Агенты могут запрашивать статус аттестаций адресов в сети, поэтому изменений в коде будет минимальное количество.

Предлагаю читателям в комментариях предложить в каких местах и как именно нужно поменять строки кода текущей версии игры, чтобы это реализовать. На помощь, как всегда, Oscript Language Reference.

Наш Discord & Twitter
Tags:
Hubs:
Total votes 9: ↑6 and ↓3+3
Comments8

Articles

Information

Website
obyte.org
Registered
Founded
Employees
Unknown