Compare commits

..

No commits in common. "2bab960f23b994ed443f445ce1fd68b496be0d40" and "0f27dc05f4ee32c59cc4755c273bc568ff7762f1" have entirely different histories.

10 changed files with 936 additions and 62527 deletions

View file

@ -3,7 +3,7 @@
Luanti mod to exchange in-game [currency](https://github.com/mt-mods/currency) with [libre currency](https://duniter.org) (Ğ1 blockchain). Luanti mod to exchange in-game [currency](https://github.com/mt-mods/currency) with [libre currency](https://duniter.org) (Ğ1 blockchain).
Luanti server has a dedicated account. Luanti server has a dedicated account.
Players transfer money on it and the server gives them a corresponding amount of in-game banknotes. Player transfer money on it and the server gives them a corresponding amount of in-game banknotes.
They can then do whatever they want with the banknotes (trade, decorate walls, burn...). They can then do whatever they want with the banknotes (trade, decorate walls, burn...).
Players can ask the server to transfer their banknotes to an account of their choice using a chat command. Players can ask the server to transfer their banknotes to an account of their choice using a chat command.
@ -13,8 +13,6 @@ It works with Ğ1v2 which is still in development/testing phase.
Put this repo in the `mods` directory. Put this repo in the `mods` directory.
Add `g1_bridge` to `secure.http_mods`.
Have a local Duniter node. Have a local Duniter node.
```bash ```bash
@ -33,7 +31,6 @@ Have a local Duniter node.
* Ensure all transfers are failproof (any communication, storage medium, server or whatever should be able to fail or rollback to a previous state, without causing double spend or money loss) * Ensure all transfers are failproof (any communication, storage medium, server or whatever should be able to fail or rollback to a previous state, without causing double spend or money loss)
* Force reception account to exist * Force reception account to exist
* Nice ATM-like interface * Nice ATM-like interface
* Accept mixed values (other banknotes values than 1)
## Workflow ## Workflow

2344
g1bridge/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -6,16 +6,13 @@ edition = "2024"
[dependencies] [dependencies]
argp = "0.4.0" argp = "0.4.0"
codec = { package = "parity-scale-codec", version = "3.6.12" } codec = { package = "parity-scale-codec", version = "3.6.12" }
hex = "0.4.3"
log = "0.4.27" log = "0.4.27"
reqwest = { version = "0.11.27", features = ["json"] }
scrypt = { version = "0.11.0", default-features = false } scrypt = { version = "0.11.0", default-features = false }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
simplelog = "0.12.2" simplelog = "0.12.2"
sp-core = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.14.0" } sp-core = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.11.0" }
sp-runtime = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.14.0" } sp-runtime = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.11.0" }
subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.37.0-duniter-substrate-v1.14.0', default-features = false, features = [ subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.35.3-duniter-substrate-v1.11.0', default-features = false, features = [
"substrate-compat", "substrate-compat",
"native", "native",
"jsonrpsee", "jsonrpsee",
@ -24,10 +21,5 @@ tiny_http = "0.12.0"
tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] }
typed-sled = "0.2.3" typed-sled = "0.2.3"
# some dependency forgot to specify revision of another git dep...
[patch.'https://github.com/w3f/fflonk']
# https://github.com/rust-lang/cargo/issues/5478
fflonk = { git = "https://github.com//w3f/fflonk.git", rev = "1e854f35e9a65d08b11a86291405cdc95baa0a35" }
[profile.release] [profile.release]
lto = "fat" lto = "fat"

View file

@ -1,12 +0,0 @@
query ReceivedTransactions($address: String!, $block: Int!) {
tx: transfer(where: { blockNumber: { _gt: 1 }, to: { id: { _eq: "" } } }) {
amount
comment {
remark
}
}
b: block(limit: 1, orderBy: { height: DESC }) {
height
hash
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,51 +0,0 @@
use serde::Deserialize;
/*
[
{
"data": {
"transfer": [
{
"amount": 42,
"comment": null
},
{
"amount": 30,
"comment": {"remark": "tuxmain"}
}
],
"block": [
{
"height": 7040132,
"hash": "\\xe669ff0d26dc9c48ec905916d39b0f11447cf63a37c0d1094f19cdfbbb199c67"
}
]
}
}
]
*/
#[derive(Debug, Deserialize)]
pub struct Comment {
pub remark: String,
}
#[derive(Debug, Deserialize)]
pub struct Transfer {
pub amount: u64,
pub comment: Option<Comment>,
}
#[derive(Debug, Deserialize)]
pub struct Block {
pub height: u64,
pub hash: String,
}
#[derive(Debug, Deserialize)]
pub struct Data {
pub transfer: Vec<Transfer>,
pub block: Vec<Block>,
}
#[derive(Debug, Deserialize)]
pub struct DataWrapper {
pub data: Data,
}
pub type Response = Vec<DataWrapper>;

View file

@ -1,23 +1,22 @@
#![feature(try_blocks)] #![feature(try_blocks)]
mod blockchain; mod blockchain;
mod indexer;
mod response;
use argp::FromArgs; use argp::FromArgs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize, de};
use sp_core::Pair as _; use sp_core::Pair as _;
use std::{ use std::{
collections::{hash_map::Entry, HashMap}, io::{BufRead, Read}, str::FromStr, sync::Arc io::{BufRead, Read},
str::FromStr,
sync::Arc,
}; };
use subxt::{ use subxt::{
ext::sp_core::ed25519::Pair, ext::sp_core::ed25519::{Pair, Public},
utils::AccountId32, utils::{AccountId32, MultiAddress},
}; };
use tiny_http::{Response, ResponseBox, Server}; use tiny_http::{Response, ResponseBox, Server, ServerConfig};
use tokio::sync::mpsc::{Sender, channel}; use tokio::sync::mpsc::{Sender, channel};
use typed_sled::Tree; use typed_sled::Tree;
use log::error;
/// Game to Blockchain /// Game to Blockchain
const TREE_DEBT_G2B: &'static str = "debt_g2b"; const TREE_DEBT_G2B: &'static str = "debt_g2b";
@ -29,10 +28,6 @@ struct Opt {
#[argp(option, short = 'd')] #[argp(option, short = 'd')]
db: String, db: String,
/// Duniter indexer URL
#[argp(option, short = 'i', default = "String::from(\"https://squid.gdev.gyroi.de/v1/graphql\")")]
indexer: String,
/// secret key is legacy (two passwords on the two first lines) /// secret key is legacy (two passwords on the two first lines)
#[argp(switch, short = 'l')] #[argp(switch, short = 'l')]
legacy: bool, legacy: bool,
@ -85,16 +80,12 @@ async fn main() {
.expect("Cannot read secret key file"); .expect("Cannot read secret key file");
Pair::from_string(sk_content.trim(), None).expect("Cannot decode secret key") Pair::from_string(sk_content.trim(), None).expect("Cannot decode secret key")
}; };
let pk: AccountId32 = pair.public().into();
let pk_str = pk.to_string();
simplelog::SimpleLogger::init(log::LevelFilter::Debug, simplelog::Config::default()).unwrap(); simplelog::SimpleLogger::init(log::LevelFilter::Debug, simplelog::Config::default()).unwrap();
let db = typed_sled::open(opt.db).expect("Cannot open database"); let db = typed_sled::open(opt.db).expect("Cannot open database");
let tree_debt_g2b = Tree::<DebtG2B, ()>::open(&db, TREE_DEBT_G2B); let tree_debt_g2b = Tree::<DebtG2B, ()>::open(&db, TREE_DEBT_G2B);
let client = reqwest::Client::new();
let (bc_send, bc_recv) = channel(128); let (bc_send, bc_recv) = channel(128);
tokio::spawn(blockchain::run( tokio::spawn(blockchain::run(
@ -109,8 +100,6 @@ async fn main() {
struct Locals { struct Locals {
tree_debt_g2b: Tree<DebtG2B, ()>, tree_debt_g2b: Tree<DebtG2B, ()>,
bc_send: Sender<DebtG2B>, bc_send: Sender<DebtG2B>,
indexer: String,
pk_str: String,
} }
tokio::task_local! { tokio::task_local! {
@ -121,13 +110,10 @@ async fn main() {
Arc::new(Locals { Arc::new(Locals {
tree_debt_g2b, tree_debt_g2b,
bc_send, bc_send,
indexer: opt.indexer,
pk_str: pk_str,
}), }),
async move { async move {
for request in server.incoming_requests() { for request in server.incoming_requests() {
let locals = LOCALS.get(); let locals = LOCALS.get();
let client = client.clone();
tokio::spawn(async move { tokio::spawn(async move {
let url = request.url(); let url = request.url();
@ -138,6 +124,7 @@ async fn main() {
Some("send") => { Some("send") => {
let address = AccountId32::from_str(url_items.next()?).ok()?; let address = AccountId32::from_str(url_items.next()?).ok()?;
let amount = url_items.next()?.parse::<u64>().ok()?; let amount = url_items.next()?.parse::<u64>().ok()?;
println!("{address} {amount}");
let debt = DebtG2B { address, amount }; let debt = DebtG2B { address, amount };
locals.tree_debt_g2b.insert(&debt, &()).ok()?; locals.tree_debt_g2b.insert(&debt, &()).ok()?;
@ -145,52 +132,6 @@ async fn main() {
Response::empty(200).boxed() Response::empty(200).boxed()
} }
Some("recv") => {
let block_number = url_items.next()?.parse::<u64>().ok()?;
let req = client.post(&locals.indexer).body(dbg!(format!(
r#"[{{"query":"{{transfer(where:{{blockNumber:{{_gt:{}}},to:{{id:{{_eq:\"{}\"}}}}}}){{amount comment{{remark}}}}block(limit:1,orderBy:{{height:DESC}}){{height hash}}}}"}}]"#,
block_number, locals.pk_str))).build().unwrap();
let Ok(res) = client.execute(req).await else {
request.respond(Response::empty(500)).ok();
error!("Error from indexer");
return;
};
let Ok(res) = res.json::<indexer::Response>().await else {
request.respond(Response::empty(500)).ok();
error!("Bad JSON from indexer");
return;
};
let Some(data) = res.first() else {
request.respond(Response::empty(500)).ok();
error!("No data from indexer");
return;
};
let Some(block) = data.data.block.first() else {
request.respond(Response::empty(500)).ok();
error!("No block from indexer");
return;
};
let mut debts = HashMap::<String, u64>::new();
for transfer in data.data.transfer.iter() {
if let Some(comment) = &transfer.comment {
match debts.entry(comment.remark.clone()) {
Entry::Occupied(mut debt) => {
*debt.get_mut() = debt.get().saturating_add(transfer.amount);
}
Entry::Vacant(debt) => {
debt.insert(transfer.amount);
}
}
}
}
Response::from_string(serde_json::to_string(&response::Response {
block: block.height,
debts: debts.into_iter().map(|(name, amount)| response::Debt {
name,
amount,
}).collect(),
}).unwrap()).with_status_code(200).boxed()
}
_ => None?, _ => None?,
} }
}; };

View file

@ -1,13 +0,0 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct Debt {
pub name: String,
pub amount: u64,
}
#[derive(Serialize)]
pub struct Response {
pub block: u64,
pub debts: Vec<Debt>,
}

112
init.lua
View file

@ -1,97 +1,49 @@
local MG_RATE = 10
local http = minetest.request_http_api() local http = minetest.request_http_api()
if http == nil then if http == nil then
error("Please add g1_bridge to secure.http_mods") error("Please add g1_bridge to secure.http_mods")
end end
local mod_storage = core.get_mod_storage() minetest.register_chatcommand("g1", {
if mod_storage == nil then
error("Cannot get mod storage")
end
function receive_debts()
local block = mod_storage:get_int("block")
http.fetch({url="http://127.0.0.1:30061/recv/"..block, method="GET"},
function(res)
if res.code == 200 then
local data = minetest.parse_json(res.data)
if data == nil or type(data.block) ~= "number" or data.block <= block or type(data.debts) ~= "table" then
return
end
for i, debt in ipairs(data.debts) do
if type(debt.name) == "string" and type(debt.amount) == "number" and core.player_exists(debt.name) then
local player_debt = mod_storage:get_int("debt_"..debt.name)
player_debt = player_debt + debt.amount
mod_storage:set_int("debt_"..debt.name, player_debt)
end
end
mod_storage:set_int("block", data.block + 1)
end
end
)
end
minetest.register_chatcommand("g1_send", {
params = "<address> <amount>",
description = "Send MineGeld from your inventory to a G1 address. Fixed rate: 1G1=10MG.",
func = function(name, param) func = function(name, param)
local args = {} local args = {}
for arg in string.gmatch(param,"%S+") do for arg in string.gmatch(param,"%S+") do
if #args >= 2 then if #args >= 3 then
break break
end end
table.insert(args, arg) table.insert(args, arg)
end end
if #args == 2 and string.find(args[1],"^[%da-zA-Z]+$") and string.find(args[2],"^%d+$") then if #args >= 1 then
local address = args[1] core.chat_send_player(name, "args >= 1")
local amount_mg = tonumber(args[2]) if args[1] == "send" and #args == 3 and string.find(args[2],"^[%da-zA-Z]+$") and string.find(args[3],"^%d+$") then
-- This version Lua does not have any way to check if a number is represented as an integer or a float, local address = args[2]
-- because it explicitely doesn't care. local amount = tonumber(args[3])
-- Hence, this program won't care if the player writes an invalid number, -- This version Lua does not have any way to check if a number is represented as an integer or a float,
-- as there is no need to ensure arithmetic rules are respected. (as Lua's manual pretends) -- because it explicitely doesn't care.
-- As the pattern excludes "." and nobody should have billions of items, -- Hence, this program won't care if the player writes an invalid number,
-- it should still be safe. I hope. -- as there is no need to ensure arithmetic rules are respected. (as Lua's manual pretends)
if type(amount_mg) ~= "number" then -- As the pattern excludes "." and nobody should have billions of items,
return -- it should still be safe. I hope.
end if type(amount) ~= "number" then
local inv = core.get_inventory({type="player", name=name}) return
local wanted_stack = "currency:minegeld "..amount_mg end
local balanced = inv:contains_item("main", ItemStack(wanted_stack)) local inv = core.get_inventory({type="player", name=name})
if balanced then local wanted_stack = "currency:minegeld "..amount
local amount = amount_mg * MG_RATE local balanced = inv:contains_item("main", ItemStack(wanted_stack))
inv:remove_item("main", ItemStack(wanted_stack)) if balanced then
http.fetch({url="http://127.0.0.1:30061/send/"..address.."/"..amount, method="GET"}, inv:remove_item("main", wanted_stack)
function(res) http.fetch({url="http://127.0.0.1:30061/send/"..address.."/"..amount, method="GET"},
if res ~= nil and res.code == 200 then function(res)
core.chat_send_player(name, "Transfer sent") if res.code == 200 then
else core.chat_send_player(name, "Transfer sent")
core.chat_send_player(name, "Error during transfer, giving your money back") else
inv:add_item("main", ItemStack(wanted_stack)) core.chat_send_player(name, "Error during transfer")
end
end end
end )
) else
else core.chat_send_player(name, "Not enough currency")
core.chat_send_player(name, "Not enough money (please ensure it is available in 1MG banknotes)") end
end end
end end
end end
}) })
minetest.register_chatcommand("g1_claim", {
description = "Claim MineGeld from G1 sent to the server",
func = function(name, param)
receive_debts()
local amount = mod_storage:get_int("debt_"..name)
local amount_mg = amount / MG_RATE
local inv = core.get_inventory({type="player", name=name})
local stack = "currency:minegeld "..amount_mg
if inv:room_for_item("main", ItemStack(stack)) then
inv:add_item("main", ItemStack(stack))
core.chat_send_player(name, "Claimed "..amount_mg.."MG")
mod_storage:set_int("debt_"..name, 0)
else
core.chat_send_player(name, "No room in your inventory! Available: "..amount_mg.."MG")
end
end
})