lib/zold/txn.rb
# frozen_string_literal: true
# Copyright (c) 2018-2024 Zerocracy
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the 'Software'), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
require 'time'
require_relative 'id'
require_relative 'hexnum'
require_relative 'amount'
require_relative 'signature'
# The transaction.
#
# Author:: Yegor Bugayenko (yegor256@gmail.com)
# Copyright:: Copyright (c) 2018-2024 Zerocracy
# License:: MIT
module Zold
# A single transaction
class Txn
# When can't parse them.
class CantParse < StandardError; end
# Regular expression for details
RE_DETAILS = '[a-zA-Z0-9 @\!\?\*_\-\.:,\'/]+'
private_constant :RE_DETAILS
# Regular expression for prefix
RE_PREFIX = '[a-zA-Z0-9]+'
private_constant :RE_PREFIX
# To validate the prefix
REGEX_PREFIX = Regexp.new("^#{RE_PREFIX}$")
private_constant :REGEX_PREFIX
# To validate details
REGEX_DETAILS = Regexp.new("^#{RE_DETAILS}$")
private_constant :REGEX_DETAILS
attr_accessor :amount, :bnf, :sign
attr_reader :id, :date, :prefix, :details
# Make a new object of this class (you must read the White Paper
# in order to understand this class).
#
# +id+:: is the ID of the transaction, an integer
# +date+:: is the date/time of the transaction
# +amount+:: is the amount, an instance of class +Amount+
# +prefix+:: is the prefix from the Invoice (read the WP)
# +bnf+:: is the wallet ID of the paying or receiving wallet
# +details+:: is the details, in plain text
def initialize(id, date, amount, prefix, bnf, details)
raise 'The ID can\'t be NIL' if id.nil?
raise "ID of transaction can't be negative: #{id}" if id < 1
@id = id
raise 'The time can\'t be NIL' if date.nil?
raise 'Time have to be of type Time' unless date.is_a?(Time)
raise "Time can't be in the future: #{date.utc.iso8601}" if date > Time.now
@date = date
raise 'The amount can\'t be NIL' if amount.nil?
raise 'The amount has to be of type Amount' unless amount.is_a?(Amount)
raise 'The amount can\'t be zero' if amount.zero?
@amount = amount
raise 'The bnf can\'t be NIL' if bnf.nil?
raise 'The bnf has to be of type Id' unless bnf.is_a?(Id)
@bnf = bnf
raise 'Prefix can\'t be NIL' if prefix.nil?
raise "Prefix is too short: #{prefix.inspect}" if prefix.length < 8
raise "Prefix is too long: #{prefix.inspect}" if prefix.length > 32
raise "Prefix is wrong: #{prefix.inspect} (#{RE_PREFIX})" unless REGEX_PREFIX.match?(prefix)
@prefix = prefix
raise 'Details can\'t be NIL' if details.nil?
raise 'Details can\'t be empty' if details.empty?
raise "Details are too long: #{details.inspect}" if details.length > 512
raise "Wrong details #{details.inspect} (#{RE_DETAILS})" unless REGEX_DETAILS.match?(details)
@details = details
end
def ==(other)
id == other.id && date == other.date && amount == other.amount &&
prefix == other.prefix && bnf == other.bnf &&
details == other.details && sign == other.sign
end
def <=>(other)
raise 'Can only compare with Txn' unless other.is_a?(Txn)
[date, amount * -1, id, bnf] <=> [other.date, other.amount * -1, other.id, other.bnf]
end
def to_s
[
Hexnum.new(@id, 4).to_s,
@date.utc.iso8601,
Hexnum.new(@amount.to_i, 16),
@prefix,
@bnf,
@details,
@sign
].join(';')
end
def to_json
{
id: @id,
date: @date.utc.iso8601,
amount: @amount.to_i,
prefix: @prefix,
bnf: @bnf.to_s,
details: @details,
sign: @sign
}
end
def to_text
start = @amount.negative? ? "##{@id}" : "(#{@id})"
"#{start} #{@date.utc.iso8601} #{@amount} #{@bnf} #{@details}"
end
def inverse(bnf)
raise 'You can\'t reverse a positive transaction' unless amount.negative?
t = clone
t.amount = amount * -1
t.bnf = bnf
t.sign = ''
t
end
# Sign the transaction and add RSA signature to it
# +pvt+:: The private RSA key of the paying wallet
# +id+:: Paying wallet ID
def signed(pvt, id)
t = clone
t.sign = Signature.new.sign(pvt, id, self)
t
end
# Pattern to match the transaction from text
PTN = Regexp.new(
[
'^',
[
'(?<id>[0-9a-f]{4})',
'(?<date>[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z)',
'(?<amount>[0-9a-f]{16})',
"(?<prefix>#{RE_PREFIX})",
'(?<bnf>[0-9a-f]{16})',
"(?<details>#{RE_DETAILS})",
'(?<sign>[A-Za-z0-9+/]+={0,3})?'
].join(';'),
'$'
].join
)
private_constant :PTN
def self.parse(line, idx = 0)
clean = line.strip
parts = PTN.match(clean)
raise CantParse, "Invalid line ##{idx}: #{line.inspect} (doesn't match #{PTN})" unless parts
txn = Txn.new(
Hexnum.parse(parts[:id]).to_i,
parse_time(parts[:date]),
Amount.new(zents: Hexnum.parse(parts[:amount]).to_i),
parts[:prefix],
Id.new(parts[:bnf]),
parts[:details]
)
txn.sign = parts[:sign]
txn
end
# When time can't be parsed.
class CantParseTime < StandardError; end
ISO8601 = Regexp.new(
[
'^',
[
'(?<year>\d{4})',
'-(?<month>\d{2})',
'-(?<day>\d{2})',
'T(?<hours>\d{2})',
':(?<minutes>\d{2})',
':(?<seconds>\d{2})Z'
].join
].join
)
private_constant :ISO8601
def self.parse_time(iso)
parts = ISO8601.match(iso)
raise CantParseTime, "Invalid ISO 8601 date \"#{iso}\"" if parts.nil?
Time.gm(
parts[:year].to_i, parts[:month].to_i, parts[:day].to_i,
parts[:hours].to_i, parts[:minutes].to_i, parts[:seconds].to_i
)
end
end
end