mirror of
https://github.com/aljazceru/hypergolic.git
synced 2025-12-23 16:24:23 +01:00
1038 lines
28 KiB
TypeScript
1038 lines
28 KiB
TypeScript
import { getAuthorizedZapper } from '@/helpers';
|
|
import { BitcoinTipTag, txo, txs } from '@/stores/bitcoin';
|
|
import { NDKEvent, type NDKTag } from '@nostr-dev-kit/ndk';
|
|
import validate from 'bitcoin-address-validation';
|
|
import { sha256 } from 'js-sha256';
|
|
import { MapOfVotes, MeritRequest, Votes } from './merits';
|
|
import * as immutable from 'immutable';
|
|
import { BloomFilter } from 'bloomfilter';
|
|
|
|
export class Rocket {
|
|
Event: NDKEvent;
|
|
private Bloom(): BloomFilter {
|
|
let b = new BloomFilter(
|
|
64 * 256, // bits to allocate.
|
|
32 // number of hashes
|
|
);
|
|
let existing = this.Event.getMatchingTags('bloom');
|
|
if (existing.length == 1) {
|
|
b = new BloomFilter(JSON.parse(existing[0][existing[0].length - 1]), 32);
|
|
}
|
|
return b;
|
|
}
|
|
private AppendEventToBloom(id: string) {
|
|
let existing = this.Bloom();
|
|
existing.add(id);
|
|
this.Event.removeTag('bloom');
|
|
this.Event.tags.push(['bloom', '32', JSON.stringify([].slice.call(existing.buckets))]);
|
|
}
|
|
Included(id: string): boolean {
|
|
return this.Bloom().test(id);
|
|
}
|
|
UpsertBitcoinAssociation(association: BitcoinAssociation): NDKEvent | undefined {
|
|
let event: NDKEvent | undefined = undefined;
|
|
if (association.Validate() && association.Event && !this.Included(association.Event.id)) {
|
|
let existing = this.BitcoinAssociations().get(association.Address!);
|
|
if ((existing && existing.Pubkey != association.Pubkey) || !existing) {
|
|
this.PrepareForUpdate(association.Event.id);
|
|
event = new NDKEvent(this.Event.ndk, this.Event.rawEvent());
|
|
event.created_at = Math.floor(new Date().getTime() / 1000);
|
|
event.tags.push(['address', `${association.Pubkey}:${association.Address}`]);
|
|
event.tags.push(['proof_full', JSON.stringify(association.Event.rawEvent())]);
|
|
updateIgnitionAndParentTag(event);
|
|
updateBitcoinTip(event);
|
|
}
|
|
}
|
|
return event;
|
|
}
|
|
BitcoinAssociations(): Map<string, BitcoinAssociation> {
|
|
let a = new Map<string, BitcoinAssociation>();
|
|
for (let t of this.Event.getMatchingTags('address')) {
|
|
if (t.length == 2) {
|
|
let split = t[1].split(':');
|
|
if (split.length == 2) {
|
|
let ba = new BitcoinAssociation();
|
|
ba.Address = split[1];
|
|
ba.Pubkey = split[0];
|
|
if (ba.Validate()) {
|
|
a.set(ba.Address, ba);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return a;
|
|
}
|
|
UpsertLeadTime(event: NDKEvent): NDKEvent {
|
|
//todo: validate that there are no current auctions that include this AMR
|
|
return new NDKEvent();
|
|
}
|
|
UpsertMeritTransfer(request: MeritPurchase): NDKEvent | undefined {
|
|
let event: NDKEvent | undefined = undefined;
|
|
if (this.PendingAMRAuctionsMap().get(request.auction.ID())) {
|
|
this.PrepareForUpdate();
|
|
let _event = new NDKEvent(this.Event.ndk, this.Event.rawEvent());
|
|
_event.created_at = Math.floor(new Date().getTime() / 1000);
|
|
//delete the auction
|
|
let auctionID = request.auction.ID();
|
|
let existing = _event.getMatchingTags('amr_auction');
|
|
_event.removeTag('amr_auction');
|
|
for (let t of existing) {
|
|
let amr = AMRAuctionFromTag(t, this.Event);
|
|
if (amr.ID() != auctionID) {
|
|
_event.tags.push(t);
|
|
}
|
|
}
|
|
_event.tags.push(['proof_raw', `txid:${request.tx.ID}`]);
|
|
|
|
let modifiedMerits: Map<string, RocketAMR> = new Map();
|
|
for (let id of request.auction.AMRIDs) {
|
|
let amr = this.ApprovedMeritRequests().get(id);
|
|
if (!amr) {
|
|
return event;
|
|
}
|
|
if (amr.LeadTime > 0) {
|
|
return event;
|
|
}
|
|
amr.Pubkey = request.buyer;
|
|
modifiedMerits.set(amr.ID, amr);
|
|
}
|
|
let existingMerits = this.ApprovedMeritRequests();
|
|
for (let [id, m] of modifiedMerits) {
|
|
existingMerits.set(id, m);
|
|
}
|
|
_event.removeTag('merit');
|
|
for (let [id, m] of existingMerits) {
|
|
_event.tags.push(m.Tag());
|
|
}
|
|
_event.tags.push([
|
|
'swap',
|
|
`${request.auction.Merits}:${request.tx.Amount}:${Math.floor(new Date().getTime() / 1000)}`
|
|
]);
|
|
let existingAssociation = this.BitcoinAssociations().get(request.tx.From);
|
|
if (
|
|
!existingAssociation ||
|
|
(existingAssociation && existingAssociation.Pubkey != request.buyer)
|
|
) {
|
|
return event;
|
|
}
|
|
if (request.tx.Change) {
|
|
let existingAssociations = this.BitcoinAssociations();
|
|
_event.removeTag('address');
|
|
for (let [_, ba] of existingAssociations) {
|
|
if (ba.Address != request.tx.From) {
|
|
_event.tags.push(ba.Tag());
|
|
}
|
|
if (ba.Address == request.tx.From) {
|
|
ba.Address = request.tx.Change;
|
|
_event.tags.push(ba.Tag());
|
|
}
|
|
}
|
|
}
|
|
|
|
updateIgnitionAndParentTag(_event);
|
|
updateBitcoinTip(_event);
|
|
event = _event;
|
|
}
|
|
return event;
|
|
}
|
|
|
|
URL(): string {
|
|
let ignitionID = undefined;
|
|
if (
|
|
this.Event.getMatchingTags('ignition') &&
|
|
this.Event.getMatchingTags('ignition')[0] &&
|
|
this.Event.getMatchingTags('ignition')[0][1]
|
|
) {
|
|
ignitionID = this.Event.getMatchingTags('ignition')[0][1];
|
|
}
|
|
if (!ignitionID) {
|
|
ignitionID = this.Event.id;
|
|
}
|
|
let d = this.Event.getMatchingTags('d')[0][1];
|
|
let p = this.Event.pubkey;
|
|
return `${ignitionID}?d=${d}&p=${p}`;
|
|
}
|
|
Name(): string {
|
|
return this.Event.dTag!;
|
|
}
|
|
Mission(): string {
|
|
if (
|
|
this.Event.getMatchingTags('mission') &&
|
|
this.Event.getMatchingTags('mission')[0] &&
|
|
this.Event.getMatchingTags('mission')[0][1]
|
|
) {
|
|
return this.Event.getMatchingTags('mission')[0][1];
|
|
}
|
|
return '';
|
|
}
|
|
Products(): Map<string, RocketProduct> {
|
|
let _products = new Map<string, RocketProduct>();
|
|
for (let p of this.Event.getMatchingTags('product')) {
|
|
let rp = new RocketProduct(p);
|
|
_products.set(rp.ID, rp);
|
|
}
|
|
return _products;
|
|
}
|
|
VotePowerForPubkey(pubkey: string): number {
|
|
let votepower = 0;
|
|
if (this.Event.pubkey == pubkey) {
|
|
//todo: calculate votepower for pubkey based on approved merit requests
|
|
votepower++;
|
|
}
|
|
return votepower;
|
|
}
|
|
TotalVotePower(): number {
|
|
//todo: calculate votepower for pubkey based on approved merit requests
|
|
return 1;
|
|
}
|
|
ApprovedMeritRequests(): Map<string, RocketAMR> {
|
|
let amr = new Map<string, RocketAMR>();
|
|
for (let m of this.Event.getMatchingTags('merit')) {
|
|
if (m && m.length == 2) {
|
|
let _amr = new RocketAMR(m[1]);
|
|
amr.set(_amr.ID, _amr);
|
|
}
|
|
}
|
|
return amr;
|
|
}
|
|
TotalMerits(): number {
|
|
let total = 0;
|
|
let amr = this.ApprovedMeritRequests();
|
|
for (let [_, _amr] of amr) {
|
|
total += _amr.Merits;
|
|
}
|
|
return total;
|
|
}
|
|
ValidateAMRProof(amrProof: NDKEvent): boolean {
|
|
let result = false;
|
|
if (this.VotePowerForPubkey(amrProof.pubkey) > 0 && amrProof.verifySignature(true)) {
|
|
let request: NDKEvent | undefined = undefined;
|
|
let votes: NDKEvent[] = [];
|
|
let _request = amrProof.getMatchingTags('request');
|
|
if (_request.length == 1) {
|
|
try {
|
|
let __request = new NDKEvent(undefined, JSON.parse(_request[0][1]));
|
|
if (__request.verifySignature(true)) {
|
|
request = __request;
|
|
}
|
|
} catch {}
|
|
}
|
|
for (let v of amrProof.getMatchingTags('vote')) {
|
|
try {
|
|
let vEv = new NDKEvent(undefined, JSON.parse(v[1]));
|
|
if (vEv.verifySignature(true)) votes.push(vEv);
|
|
} catch {}
|
|
}
|
|
if (request && votes.length > 0) {
|
|
let parsedRequest = new MeritRequest(request);
|
|
let mapOfVotes = new MapOfVotes(votes, this, parsedRequest).Votes;
|
|
let parsedVotes = new Votes(Array.from(mapOfVotes, ([_, v]) => v));
|
|
let voteDirection = parsedVotes.Results().Result(this);
|
|
if (
|
|
voteDirection &&
|
|
voteDirection == 'ratify' &&
|
|
!parsedRequest.IncludedInRocketState(this)
|
|
) {
|
|
//note: if it is included in the rocket state, we might be validating this against a previous state
|
|
result = true;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
CreateUnsignedAMRProof(request: MeritRequest, votes: Votes): NDKEvent | undefined {
|
|
let proof: NDKEvent | undefined = undefined;
|
|
let hasInvalidSig = false;
|
|
if (request && request.Event && request.Event.sig && votes.Votes.length > 0) {
|
|
if (!request.Event.verifySignature(true)) {
|
|
hasInvalidSig = true;
|
|
}
|
|
for (let v of votes.Votes) {
|
|
if (!(v.Event.sig && v.Event.verifySignature(true))) {
|
|
hasInvalidSig = true;
|
|
}
|
|
}
|
|
let result = votes.Results().Result(this);
|
|
if (result && result == 'ratify' && !request.IncludedInRocketState(this) && !hasInvalidSig) {
|
|
let e = new NDKEvent();
|
|
e.kind = 1411;
|
|
e.tags.push(['request', JSON.stringify(request.Event.rawEvent())]);
|
|
for (let v of votes.Votes) {
|
|
e.tags.push(['vote', JSON.stringify(v.Event.rawEvent())]);
|
|
}
|
|
proof = e;
|
|
}
|
|
}
|
|
return proof;
|
|
}
|
|
UpsertAMR(request: MeritRequest, signedProof: NDKEvent): NDKEvent | undefined {
|
|
let event: NDKEvent | undefined = undefined;
|
|
if (this.ValidateAMRProof(signedProof)) {
|
|
this.PrepareForUpdate();
|
|
event = new NDKEvent(this.Event.ndk, this.Event.rawEvent());
|
|
event.created_at = Math.floor(new Date().getTime() / 1000);
|
|
event.tags.push(['merit', `${request.Pubkey}:${request.ID}:0:0:${request.Merits}`]);
|
|
event.tags.push(['proof_full', JSON.stringify(signedProof.rawEvent())]);
|
|
updateIgnitionAndParentTag(event);
|
|
updateBitcoinTip(event);
|
|
}
|
|
return event;
|
|
}
|
|
PendingAMRAuctionsMap(): Map<string, AMRAuction> {
|
|
let m = new Map<string, AMRAuction>();
|
|
for (let t of this.Event.getMatchingTags('amr_auction')) {
|
|
let auction = AMRAuctionFromTag(t, this.Event);
|
|
if (auction.Validate()) {
|
|
let amrs = this.ApprovedMeritRequests();
|
|
let failed = false;
|
|
for (let id of auction.AMRIDs) {
|
|
let amr = amrs.get(id);
|
|
if (!amr) {
|
|
failed = true;
|
|
} else {
|
|
if (!auction.Owner) {
|
|
auction.Owner = amr.Pubkey;
|
|
} else if (auction.Owner != amr.Pubkey) {
|
|
failed = true;
|
|
}
|
|
}
|
|
}
|
|
if (!failed) {
|
|
m.set(auction.ID(), auction);
|
|
} else {
|
|
throw new Error('this should not happen, bug!');
|
|
}
|
|
}
|
|
}
|
|
return m;
|
|
}
|
|
PendingAMRAuctions(): AMRAuction[] {
|
|
return Array.from(this.PendingAMRAuctionsMap(), ([_, amr]) => {
|
|
return amr;
|
|
});
|
|
}
|
|
CanThisAMRBeSold(amr: string): boolean {
|
|
let valid = true;
|
|
let existing = this.ApprovedMeritRequests().get(amr);
|
|
if (!existing) {
|
|
valid = false;
|
|
}
|
|
if (existing && existing.LeadTime > 0) {
|
|
valid = false;
|
|
}
|
|
let pending = this.PendingAMRAuctions();
|
|
for (let p of pending) {
|
|
if (p.AMRIDs.includes(amr)) {
|
|
valid = false;
|
|
}
|
|
}
|
|
return valid;
|
|
}
|
|
UpsertAMRAuction(request: AMRAuction): NDKEvent | undefined {
|
|
//todo: validate that all items in the request exist and the total amount is correct, from same pubkey
|
|
let event: NDKEvent | undefined = undefined;
|
|
let invalid = false;
|
|
if (request.ValidateAgainstRocket(this)) {
|
|
this.PrepareForUpdate();
|
|
event = new NDKEvent(this.Event.ndk, this.Event.rawEvent());
|
|
event.created_at = Math.floor(new Date().getTime() / 1000);
|
|
let totalMerits = 0;
|
|
let requestIDs: string = '';
|
|
for (let id of request.AMRIDs) {
|
|
let amr = this.ApprovedMeritRequests().get(id);
|
|
if (!amr) {
|
|
invalid = true;
|
|
} else {
|
|
if (amr.LeadTime > 0 || amr.Pubkey != request.Owner) {
|
|
invalid = true;
|
|
} else {
|
|
totalMerits += amr.Merits;
|
|
requestIDs += id;
|
|
}
|
|
}
|
|
}
|
|
if (totalMerits != request.Merits) {
|
|
invalid = true;
|
|
}
|
|
event.tags.push([
|
|
'amr_auction',
|
|
`${request.RxAddress}:${0}:${request.StartPrice}:${request.EndPrice}:${request.Merits}:${requestIDs}`
|
|
]); //<merit request ID:start price:end price:start height:rx address>
|
|
event.tags.push(['proof_full', JSON.stringify(request.Event!.rawEvent())]);
|
|
updateIgnitionAndParentTag(event);
|
|
updateBitcoinTip(event);
|
|
}
|
|
if (invalid) {
|
|
event = undefined;
|
|
}
|
|
return event;
|
|
}
|
|
UpsertProduct(id: string, price: number, maxSales?: number): NDKEvent {
|
|
this.PrepareForUpdate(id);
|
|
let event = new NDKEvent(this.Event.ndk, this.Event.rawEvent());
|
|
event.created_at = Math.floor(new Date().getTime() / 1000);
|
|
let existingProducts = this.CurrentProducts();
|
|
let purchases = JSON.stringify([]);
|
|
let existingProduct = existingProducts.get(id);
|
|
if (existingProduct) {
|
|
purchases = existingProduct.PurchasesJSON();
|
|
}
|
|
event.tags.push([
|
|
'product',
|
|
`${id}:${price}:${event.created_at}:${maxSales}`,
|
|
'wss://relay.nostrocket.org',
|
|
purchases
|
|
]);
|
|
updateIgnitionAndParentTag(event);
|
|
updateBitcoinTip(event);
|
|
return event;
|
|
}
|
|
UpdateMission(mission: string): NDKEvent {
|
|
this.PrepareForUpdate();
|
|
let event = new NDKEvent(this.Event.ndk, this.Event.rawEvent());
|
|
event.created_at = Math.floor(new Date().getTime() / 1000);
|
|
event.removeTag('mission');
|
|
event.tags.push(['mission', mission]);
|
|
updateIgnitionAndParentTag(event);
|
|
updateBitcoinTip(event);
|
|
return event;
|
|
}
|
|
CurrentProducts(): Map<string, RocketProduct> {
|
|
return getMapOfProductsFromRocket(this.Event);
|
|
}
|
|
RemoveDuplicateTags() {
|
|
function iterate(event: NDKEvent): NDKEvent {
|
|
let purged = 0;
|
|
for (let i = 0; i < event.tags.length; i++) {
|
|
for (let j = i + 1; j < event.tags.length; j++) {
|
|
// quick elimination by comparing sub-array lengths
|
|
if (event.tags[i].length !== event.tags[j].length) {
|
|
continue;
|
|
}
|
|
// look for dupes
|
|
var dupe = true;
|
|
for (var k = 0; k < event.tags[i].length; k++) {
|
|
if (event.tags[i][k] !== event.tags[j][k]) {
|
|
dupe = false;
|
|
break;
|
|
}
|
|
}
|
|
// if a dupe then remove it
|
|
if (dupe) {
|
|
purged++;
|
|
event.tags.splice(j, 1);
|
|
}
|
|
}
|
|
}
|
|
if (purged > 0) {
|
|
return iterate(event);
|
|
} else {
|
|
return event;
|
|
}
|
|
}
|
|
this.Event = iterate(this.Event);
|
|
}
|
|
RemoveProofs() {
|
|
let newTags: NDKTag[] = [];
|
|
for (let t of this.Event.tags) {
|
|
if (!t[0].includes('proof') && t[0] != 'client') {
|
|
newTags.push(t);
|
|
}
|
|
}
|
|
this.Event.tags = newTags;
|
|
}
|
|
PrepareForUpdate(id?: string) {
|
|
this.RemoveDuplicateTags();
|
|
this.RemoveProofs();
|
|
if (id) {
|
|
this.AppendEventToBloom(id);
|
|
}
|
|
this.Event.sig = undefined;
|
|
}
|
|
constructor(event: NDKEvent) {
|
|
this.Event = event;
|
|
}
|
|
}
|
|
|
|
function updateIgnitionAndParentTag(event: NDKEvent) {
|
|
let existingIgnition = event.getMatchingTags('ignition');
|
|
//let existingParent = rocket.getMatchingTags("parent")
|
|
let existing = [];
|
|
for (let t of event.tags) {
|
|
existing.push(t);
|
|
}
|
|
event.tags = [];
|
|
for (let t of existing) {
|
|
if (t[0] !== 'ignition' && t[0] !== 'parent') {
|
|
event.tags.push(t);
|
|
}
|
|
}
|
|
if (existingIgnition.length > 1) {
|
|
throw new Error('too many ignition tags!');
|
|
}
|
|
if (existingIgnition.length == 0) {
|
|
event.tags.push(['ignition', event.id]);
|
|
}
|
|
if (existingIgnition.length == 1) {
|
|
if (existingIgnition[0][1].length == 64) {
|
|
event.tags.push(existingIgnition[0]);
|
|
}
|
|
if (existingIgnition[0][1] == 'this') {
|
|
event.tags.push(['ignition', event.id]);
|
|
}
|
|
}
|
|
event.tags.push(['parent', event.id]);
|
|
}
|
|
|
|
function updateBitcoinTip(event: NDKEvent) {
|
|
let existingBitcoinTip = event.getMatchingTags('bitcoin');
|
|
let existing = [];
|
|
for (let t of event.tags) {
|
|
existing.push(t);
|
|
}
|
|
event.tags = [];
|
|
for (let t of existing) {
|
|
if (t[0] !== 'bitcoin') {
|
|
event.tags.push(t);
|
|
}
|
|
}
|
|
if (existingBitcoinTip.length > 1) {
|
|
throw new Error('too many bitcoin tip tags!');
|
|
} else {
|
|
event.tags.push(BitcoinTipTag());
|
|
}
|
|
}
|
|
|
|
export class RocketAMR {
|
|
//todo: also add a query for sats tags to find payments for this AMR
|
|
ID: string;
|
|
Pubkey: string;
|
|
LeadTime: number;
|
|
LeadTimeUpdate: number;
|
|
Merits: number;
|
|
Extra: { eventAMR: AMRAuction };
|
|
Tag(): NDKTag {
|
|
return [
|
|
'merit',
|
|
`${this.Pubkey}:${this.ID}:${this.LeadTime}:${this.LeadTimeUpdate}:${this.Merits}`
|
|
];
|
|
}
|
|
SatsOwed(): number {
|
|
//if rocket creator is acting as custodian instead of using a cashu mint
|
|
return 0;
|
|
}
|
|
SatsPaid(): number {
|
|
return 0;
|
|
}
|
|
Valid(): boolean {
|
|
let valid = true;
|
|
if (!(this.ID.length == 64 && this.Pubkey.length == 64 && this.Merits)) {
|
|
valid = false;
|
|
}
|
|
return valid;
|
|
}
|
|
constructor(meritString: string) {
|
|
let split = meritString.split(':');
|
|
if (split.length == 5) {
|
|
this.Pubkey = split[0];
|
|
this.ID = split[1];
|
|
this.LeadTime = parseInt(split[2], 10);
|
|
this.LeadTimeUpdate = parseInt(split[3], 10);
|
|
this.Merits = parseInt(split[4], 10);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class RocketProduct {
|
|
ID: string;
|
|
Price: number;
|
|
ValidAfter: number; //unix time
|
|
MaxPurchases: number;
|
|
Purchases: Map<string, ProductPayment>;
|
|
PurchasesJSON(): string {
|
|
let purchases = [];
|
|
for (let [_, p] of this.Purchases) {
|
|
purchases.push(`${p.ZapID}:${p.BuyerPubkey}:${p.WitnessedAt}`);
|
|
}
|
|
return JSON.stringify(purchases);
|
|
}
|
|
constructor(tag: NDKTag) {
|
|
this.Purchases = new Map();
|
|
this.ID = tag[1].split(':')[0];
|
|
this.Price = parseInt(tag[1].split(':')[1], 10);
|
|
this.ValidAfter = parseInt(tag[1].split(':')[2], 10);
|
|
this.MaxPurchases = parseInt(tag[1].split(':')[3], 10);
|
|
let purchases = JSON.parse(tag[3]);
|
|
for (let p of purchases) {
|
|
let payment = new ProductPayment(p);
|
|
this.Purchases.set(payment.ZapID, payment);
|
|
}
|
|
}
|
|
}
|
|
|
|
//ProductPayment takes the payment string from a product tag on a rocket event
|
|
export class ProductPayment {
|
|
ZapID: string;
|
|
BuyerPubkey: string;
|
|
WitnessedAt: number;
|
|
constructor(purchase: string) {
|
|
this.ZapID = purchase.split(':')[0];
|
|
this.BuyerPubkey = purchase.split(':')[1];
|
|
this.WitnessedAt = parseInt(purchase.split(':')[2], 10);
|
|
}
|
|
}
|
|
|
|
export function getMapOfProductsFromRocket(rocket: NDKEvent): Map<string, RocketProduct> {
|
|
let productIDs = new Map<string, RocketProduct>();
|
|
for (let product of rocket.getMatchingTags('product')) {
|
|
if (product.length > 1 && product[1].split(':') && product[1].split(':').length > 0) {
|
|
productIDs.set(product[1].split(':')[0], new RocketProduct(product));
|
|
}
|
|
}
|
|
return productIDs;
|
|
}
|
|
|
|
export class ZapPurchase {
|
|
Amount: number;
|
|
ProductID: string;
|
|
BuyerPubkey: string;
|
|
ZapReceipt: NDKEvent;
|
|
ZapRequest(): NDKEvent | undefined {
|
|
return getZapRequest(this.ZapReceipt);
|
|
}
|
|
IncludedInRocketState(rocket: NDKEvent): boolean {
|
|
let thisProduct = this.ProductFromRocket(rocket);
|
|
if (thisProduct) {
|
|
return thisProduct.Purchases.get(this.ZapReceipt.id) ? true : false;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
ProductFromRocket(rocket: NDKEvent): RocketProduct | undefined {
|
|
let productsInRocket = getMapOfProductsFromRocket(rocket);
|
|
return productsInRocket.get(this.ProductID);
|
|
}
|
|
ValidAmount(rocket: NDKEvent): boolean {
|
|
if (this.Amount < 1) {
|
|
return false;
|
|
}
|
|
if (this.IncludedInRocketState(rocket)) {
|
|
return true;
|
|
}
|
|
let product = this.ProductFromRocket(rocket);
|
|
if (product && this.Amount / 1000 >= product.Price) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
Valid(rocket: NDKEvent): boolean {
|
|
//todo: validate zapper pubkey is from a LSP specified in rocket
|
|
let valid = true;
|
|
if (!this.ValidAmount(rocket)) {
|
|
valid = false;
|
|
}
|
|
if (!this.ProductID) {
|
|
valid = false;
|
|
}
|
|
if (this.ProductID && this.ProductID.length != 64) {
|
|
valid = false;
|
|
}
|
|
if (this.BuyerPubkey.length != 64) {
|
|
valid = false;
|
|
}
|
|
return valid;
|
|
}
|
|
constructor(zapReceipt: NDKEvent) {
|
|
this.ZapReceipt = zapReceipt;
|
|
this.Amount = getZapAmount(this.ZapRequest());
|
|
let zapRequest = this.ZapRequest();
|
|
if (zapRequest) {
|
|
this.BuyerPubkey = zapRequest.pubkey;
|
|
let products = zapRequest.getMatchingTags('product');
|
|
if (products.length == 1 && products[0] && products[0][1] && products[0][1].length == 64) {
|
|
this.ProductID = products[0][1];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getZapRequest(zapReceipt: NDKEvent): NDKEvent | undefined {
|
|
let zapRequestEvent: NDKEvent | undefined = undefined;
|
|
let zapRequest = zapReceipt.getMatchingTags('description');
|
|
if (zapRequest.length == 1) {
|
|
let zapRequestJSON = JSON.parse(zapRequest[0][1]);
|
|
if (zapRequestJSON) {
|
|
zapRequestEvent = new NDKEvent(zapReceipt.ndk, zapRequestJSON);
|
|
}
|
|
}
|
|
return zapRequestEvent;
|
|
}
|
|
|
|
function getZapAmount(zapRequest?: NDKEvent): number {
|
|
return getNumberFromTag('amount', zapRequest);
|
|
}
|
|
|
|
export function getNumberFromTag(tag: string, event?: NDKEvent): number {
|
|
let amountTag = event?.getMatchingTags(tag);
|
|
if (amountTag && amountTag[0] && amountTag[0][1]) {
|
|
try {
|
|
let amount = parseInt(amountTag[0][1], 10);
|
|
return amount;
|
|
} catch {
|
|
console.log('ERROR: could not find number in tag: ', tag, event);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export function isValidUrl(string: string): boolean {
|
|
try {
|
|
new URL(string);
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function RocketATagFilter(rocket: NDKEvent): string {
|
|
return `31108:${rocket.pubkey}:${rocket.dTag}`;
|
|
}
|
|
|
|
export async function ValidateZapPublisher(rocket: NDKEvent, zap: NDKEvent): Promise<boolean> {
|
|
return new Promise((resolve, reject) => {
|
|
getAuthorizedZapper(rocket)
|
|
.then((pubkey) => {
|
|
if (pubkey == zap.pubkey) {
|
|
resolve(true);
|
|
} else {
|
|
reject();
|
|
}
|
|
})
|
|
.catch(reject);
|
|
// let z = new NDKZap({ ndk: rocket.ndk!, zappedEvent: rocket, zappedUser: rocket.author });
|
|
// z.getZapEndpoint().then(x=>{
|
|
// console.log(x)
|
|
// resolve(true)
|
|
// }).catch(()=>{reject(false)})
|
|
});
|
|
}
|
|
|
|
type AMRAuctionStatus =
|
|
| 'PENDING'
|
|
| 'OPEN'
|
|
| 'TX DETECTED'
|
|
| 'SOLD & PENDING RATIFICATION'
|
|
| 'CHECKING MEMPOOL';
|
|
|
|
export class AMRAuction {
|
|
AMRIDs: string[];
|
|
Owner: string | undefined;
|
|
StartPrice: number;
|
|
EndPrice: number;
|
|
RxAddress: string;
|
|
RocketD: string;
|
|
RocketP: string;
|
|
Merits: number;
|
|
Event: NDKEvent | undefined;
|
|
Extra: { rocket: Rocket };
|
|
ID(): string {
|
|
this.AMRIDs.sort();
|
|
return sha256(''.concat(...this.AMRIDs).trim());
|
|
}
|
|
Status(rocket: Rocket, bitcoinTip: number, transactions?: txs): AMRAuctionStatus {
|
|
let status: AMRAuctionStatus = 'PENDING';
|
|
if (transactions && transactions.Address != this.RxAddress) {
|
|
throw new Error('invalid address');
|
|
}
|
|
if (transactions) {
|
|
status = 'CHECKING MEMPOOL';
|
|
for (let [t, txo] of transactions.From()) {
|
|
//todo: implement pricing based on block height
|
|
if (txo.Amount == this.EndPrice && txo.To == this.RxAddress) {
|
|
if (txo.Height > 0 && txo.Height < bitcoinTip) {
|
|
status = 'SOLD & PENDING RATIFICATION';
|
|
} else {
|
|
status = 'TX DETECTED';
|
|
}
|
|
}
|
|
}
|
|
let found = false;
|
|
for (let pending of rocket.PendingAMRAuctions()) {
|
|
this.AMRIDs.sort();
|
|
pending.AMRIDs.sort();
|
|
if (
|
|
pending.Owner == this.Owner &&
|
|
pending.Merits == this.Merits &&
|
|
pending.RxAddress == this.RxAddress &&
|
|
pending.AMRIDs[0] == this.AMRIDs[0] //todo: check whole array
|
|
) {
|
|
found = true;
|
|
if (status == 'CHECKING MEMPOOL') {
|
|
if (Math.floor(new Date().getTime() / 1000) < transactions.LastUpdate + 60000) {
|
|
status = 'OPEN';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
GenerateEvent(): NDKEvent {
|
|
let e = new NDKEvent();
|
|
e.kind = 1412;
|
|
e.created_at = Math.floor(new Date().getTime() / 1000);
|
|
for (let id of this.AMRIDs) {
|
|
e.tags.push(['request', id]);
|
|
}
|
|
e.tags.push(['a', `31108:${this.RocketP}:${this.RocketD}`]);
|
|
//todo: allow user to set start and end auction price
|
|
|
|
e.tags.push(['price', this.StartPrice + ':' + this.EndPrice]);
|
|
e.tags.push(['merits', this.Merits.toString()]);
|
|
e.tags.push(['onchain', this.RxAddress]);
|
|
return e;
|
|
}
|
|
Push(amr: RocketAMR) {
|
|
if (this.Owner && amr.Pubkey != this.Owner) {
|
|
throw new Error('invalid pubkey');
|
|
}
|
|
this.Owner = amr.Pubkey;
|
|
this.AMRIDs.push(amr.ID);
|
|
this.StartPrice += amr.Merits;
|
|
this.EndPrice += amr.Merits;
|
|
this.Merits += amr.Merits;
|
|
}
|
|
Pop(amr: RocketAMR) {
|
|
if (this.AMRIDs.includes(amr.ID) && amr.Pubkey == this.Owner) {
|
|
let n: string[] = [];
|
|
for (let id of this.AMRIDs) {
|
|
if (id != amr.ID) {
|
|
n.push(id);
|
|
}
|
|
}
|
|
this.AMRIDs = n;
|
|
//todo: allow user to set start/end price
|
|
this.StartPrice -= amr.Merits;
|
|
this.EndPrice -= amr.Merits;
|
|
this.Merits -= amr.Merits;
|
|
}
|
|
}
|
|
Validate(): boolean {
|
|
let valid = true;
|
|
if (
|
|
(this.Owner && this.Owner.length != 64) ||
|
|
!this.StartPrice ||
|
|
!this.EndPrice ||
|
|
!validate(this.RxAddress) ||
|
|
this.RocketP.length != 64 ||
|
|
!this.Merits
|
|
) {
|
|
//console.log(780, this, (this.Owner && this.Owner.length != 64), !this.StartPrice, !this.EndPrice, !validate(this.RxAddress), this.RocketP.length != 64, !this.Merits)
|
|
valid = false;
|
|
}
|
|
for (let id of this.AMRIDs) {
|
|
if (id.length != 64) {
|
|
valid = false;
|
|
}
|
|
}
|
|
return valid;
|
|
}
|
|
ValidateAgainstRocket(rocket: Rocket): boolean {
|
|
let valid = true;
|
|
for (let id of this.AMRIDs) {
|
|
let rocketAMR = rocket.ApprovedMeritRequests().get(id);
|
|
if (!rocketAMR || (rocketAMR && rocketAMR.Pubkey != this.Owner) || rocketAMR.LeadTime > 0) {
|
|
valid = false;
|
|
}
|
|
for (let pending of rocket.PendingAMRAuctions()) {
|
|
if (pending.AMRIDs.includes(id)) {
|
|
valid = false;
|
|
}
|
|
}
|
|
}
|
|
return valid;
|
|
}
|
|
constructor(rocket?: NDKEvent, event?: NDKEvent, address?: string) {
|
|
this.AMRIDs = [];
|
|
this.Merits = 0;
|
|
this.EndPrice = 0;
|
|
this.StartPrice = 0;
|
|
if (rocket && !event) {
|
|
this.RxAddress = address ? address : '';
|
|
this.RocketD = rocket.dTag!;
|
|
this.RocketP = rocket.author.pubkey;
|
|
}
|
|
if (event && !rocket) {
|
|
this.Event = event;
|
|
for (let id of event.getMatchingTags('request')) {
|
|
if (id && id.length == 2 && id[1].length == 64) {
|
|
this.AMRIDs.push(id[1]);
|
|
}
|
|
}
|
|
this.Owner = event.author.pubkey;
|
|
let price = event.tagValue('price');
|
|
if (price) {
|
|
let _start = price.split(':')[0];
|
|
let _end = price.split(':')[1];
|
|
let start = parseInt(_start, 10);
|
|
let end = parseInt(_end, 10);
|
|
this.StartPrice = start;
|
|
this.EndPrice = end;
|
|
}
|
|
let merits = event.tagValue('merits');
|
|
if (merits) {
|
|
let int = parseInt(merits, 10);
|
|
this.Merits = int;
|
|
}
|
|
let address = event.tagValue('onchain');
|
|
if (address) {
|
|
if (validate(address)) {
|
|
this.RxAddress = address;
|
|
}
|
|
}
|
|
let _rocket = event.tagValue('a');
|
|
if (_rocket) {
|
|
if (_rocket.split(':').length == 3) {
|
|
this.RocketP = _rocket.split(':')[1];
|
|
this.RocketD = _rocket.split(':')[2];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export class BitcoinAssociation {
|
|
Pubkey: string;
|
|
Address: string | undefined;
|
|
Event: NDKEvent;
|
|
Balance: number;
|
|
Tag(): NDKTag {
|
|
return ['address', `${this.Pubkey}:${this.Address}`];
|
|
}
|
|
Validate(): boolean {
|
|
let valid = true;
|
|
if (this.Pubkey.length != 64) {
|
|
valid = false;
|
|
}
|
|
if ((this.Address && !validate(this.Address)) || !this.Address) {
|
|
valid = false;
|
|
}
|
|
return valid;
|
|
}
|
|
|
|
constructor(event?: NDKEvent) {
|
|
this.Balance = 0;
|
|
if (event) {
|
|
this.Pubkey = event.pubkey;
|
|
this.Address = event.tagValue('onchain');
|
|
this.Event = event;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class Product {
|
|
Event: NDKEvent;
|
|
Group(): string {
|
|
let s = this.Name();
|
|
//let regex = /\[\w+\]/i;
|
|
//if (regex.test(this.Name())) {
|
|
let g = this.Name().substring(this.Name().indexOf('[') + 1, this.Name().lastIndexOf(']'));
|
|
if (g.length > 0) {
|
|
s = g;
|
|
}
|
|
//}
|
|
return s;
|
|
}
|
|
Option(): string {
|
|
let result = '';
|
|
let group = this.Name().substring(this.Name().indexOf('['), this.Name().lastIndexOf(']') + 1);
|
|
if (group.length > 0) {
|
|
for (let s of this.Name().trim().split(group)) {
|
|
if (s.trim().length > 0) {
|
|
result = s.trim();
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
ID(): string {
|
|
return this.Event.id;
|
|
}
|
|
Name(): string {
|
|
return this.Event.getMatchingTags('name')[0][1];
|
|
}
|
|
Description(): string {
|
|
return this.Event.getMatchingTags('description')[0][1];
|
|
}
|
|
CoverImage(): string {
|
|
return this.Event.getMatchingTags('cover')[0][1];
|
|
}
|
|
Validate(): boolean {
|
|
let test = 0;
|
|
if (
|
|
this.Event.getMatchingTags('name') &&
|
|
this.Event.getMatchingTags('name')[0] &&
|
|
this.Event.getMatchingTags('name')[0][1]
|
|
) {
|
|
test++;
|
|
}
|
|
if (
|
|
this.Event.getMatchingTags('description') &&
|
|
this.Event.getMatchingTags('description')[0] &&
|
|
this.Event.getMatchingTags('description')[0][1]
|
|
) {
|
|
test++;
|
|
}
|
|
if (
|
|
this.Event.getMatchingTags('cover') &&
|
|
this.Event.getMatchingTags('cover')[0] &&
|
|
this.Event.getMatchingTags('cover')[0][1]
|
|
) {
|
|
test++;
|
|
}
|
|
return test == 3;
|
|
}
|
|
constructor(event: NDKEvent) {
|
|
this.Event = event;
|
|
}
|
|
}
|
|
|
|
export class MeritPurchase {
|
|
auction: AMRAuction;
|
|
buyer: string;
|
|
tx: txo;
|
|
rocket: Rocket;
|
|
Validate(): boolean {
|
|
//todo: at least validate the utxo format
|
|
return true;
|
|
}
|
|
constructor(rocket: Rocket, auction: AMRAuction, buyer: string, tx: txo) {
|
|
this.rocket = rocket;
|
|
this.auction = auction;
|
|
this.buyer = buyer;
|
|
this.tx = tx;
|
|
}
|
|
}
|
|
|
|
function AMRAuctionFromTag(t: NDKTag, rocket: NDKEvent): AMRAuction {
|
|
let a = new AMRAuction(rocket);
|
|
if (t.length == 2) {
|
|
let items = t[1].split(':');
|
|
if (items.length == 6) {
|
|
a.RxAddress = items[0];
|
|
a.StartPrice = parseInt(items[2], 10);
|
|
a.EndPrice = parseInt(items[3], 10);
|
|
a.Merits = parseInt(items[4], 10);
|
|
|
|
let ids = items[5].match(/.{1,64}/g);
|
|
if (ids) {
|
|
for (let id of ids) {
|
|
a.AMRIDs.push(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return a;
|
|
}
|