@zerc20/wallet-sdk 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -20
- package/README.md +1 -1
- package/dist/chain.d.ts +6 -5
- package/dist/chain.js +8 -7
- package/dist/chain.js.map +1 -1
- package/dist/context.js +1 -1
- package/dist/context.js.map +1 -1
- package/dist/core.js +3 -2
- package/dist/core.js.map +1 -1
- package/dist/errors.d.ts +20 -3
- package/dist/errors.js +7 -1
- package/dist/errors.js.map +1 -1
- package/dist/network.d.ts +4 -2
- package/dist/network.js +3 -1
- package/dist/network.js.map +1 -1
- package/dist/pos.d.ts +3 -0
- package/dist/pos.js +6 -0
- package/dist/pos.js.map +1 -1
- package/dist/prove.d.ts +3 -0
- package/dist/prove.js +9 -0
- package/dist/prove.js.map +1 -1
- package/dist/servers.d.ts +3 -0
- package/dist/servers.js +1 -0
- package/dist/servers.js.map +1 -1
- package/dist/services.d.ts +3 -2
- package/dist/services.js +9 -7
- package/dist/services.js.map +1 -1
- package/dist/spend.d.ts +30 -9
- package/dist/spend.js +135 -58
- package/dist/spend.js.map +1 -1
- package/dist/wallet.d.ts +40 -7
- package/dist/wallet.js +153 -37
- package/dist/wallet.js.map +1 -1
- package/package.json +3 -3
- package/src/chain.ts +12 -6
- package/src/context.ts +1 -1
- package/src/core.ts +3 -2
- package/src/errors.ts +15 -2
- package/src/network.ts +6 -3
- package/src/pos.ts +7 -0
- package/src/prove.ts +10 -0
- package/src/servers.ts +5 -0
- package/src/services.ts +8 -7
- package/src/spend.ts +146 -68
- package/src/wallet.ts +192 -38
package/src/spend.ts
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
// and deposits into one note to self, proved by the merge prover (a wallet cannot: its key is 6.3 GB). More take
|
|
7
7
|
// another merge, once a root is over the first one's note.
|
|
8
8
|
//
|
|
9
|
-
// A relayed JoinSplit or merge is done once its
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// A relayed JoinSplit or merge is done once its change (a merge's note) is final where this wallet expects it, as sync
|
|
10
|
+
// sees it: only its own transaction puts that note there, as its salt is fresh and the proof binds it to the inputs'
|
|
11
|
+
// nullifiers. The relayer's answer alone could be a lie, a reorg could drop the transaction, and a spent input alone
|
|
12
|
+
// could be another device's doing: each device of a wallet locks its inputs in its own state. Only one that cannot
|
|
13
|
+
// land fails, which frees its inputs: one that kept reverting with no input spent, or one whose input another
|
|
14
|
+
// transaction spent.
|
|
12
15
|
|
|
13
16
|
import { type Receipt, type Recipient, commit, decodeCode, planPayCode, planSelf, planSend } from '@zerc20/storage-sdk';
|
|
14
17
|
import {
|
|
@@ -27,16 +30,7 @@ import { fail } from './errors.js';
|
|
|
27
30
|
import { prove } from './prove.js';
|
|
28
31
|
import { scan } from './scan.js';
|
|
29
32
|
import type { Indexer } from './servers.js';
|
|
30
|
-
import {
|
|
31
|
-
type Relayed,
|
|
32
|
-
RelayUnknown,
|
|
33
|
-
attest,
|
|
34
|
-
proofOf,
|
|
35
|
-
relay,
|
|
36
|
-
relayMerge,
|
|
37
|
-
relayerFees,
|
|
38
|
-
requestProof,
|
|
39
|
-
} from './services.js';
|
|
33
|
+
import { attest, proofOf, relay, relayMerge, relayerFees, requestProof } from './services.js';
|
|
40
34
|
import { type NoteRecord, type OperationRecord, type Position, type SpendOperation, unspent } from './state.js';
|
|
41
35
|
import { commitmentsAt, noteReceipt, storageState, takeRefresh, tokenIds } from './sync.js';
|
|
42
36
|
import { aspPath, aspPaths, commitmentPath, commitmentPaths } from './tree.js';
|
|
@@ -77,9 +71,14 @@ export interface Step {
|
|
|
77
71
|
attempts: number;
|
|
78
72
|
/** Once relayed: the block the relayer saw it mined in. */
|
|
79
73
|
relayedBlock?: number;
|
|
80
|
-
/** The indexer's nullifier count once it is past that block, or just after a relay that
|
|
81
|
-
*
|
|
74
|
+
/** The indexer's nullifier count once it is past that block, or just after a relay that reverted on an input spent
|
|
75
|
+
* already: that leaves the step `attested`, not relayed again until its change tells whose transaction spent it.
|
|
76
|
+
* Final nullifiers past it, and its inputs still not among them, means whatever spent them is gone: a reorg dropped
|
|
77
|
+
* it, or the relayer never sent it. */
|
|
82
78
|
relayedAt?: number;
|
|
79
|
+
/** The final commitment count once an input is seen spent. Final commitments past it, and the change still not among
|
|
80
|
+
* them, means another transaction spent the input, and this step can never land. */
|
|
81
|
+
spentAt?: number;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
/** The most inputs one merge spends: the attester takes requests up to 2 MB, at 8 KiB of sealed witness per input
|
|
@@ -103,9 +102,13 @@ export interface MergeStep {
|
|
|
103
102
|
/** Refused by the prover, or reverting with no sign of it on chain: built again with the same output a few times,
|
|
104
103
|
* then failed. */
|
|
105
104
|
attempts: number;
|
|
106
|
-
/**
|
|
105
|
+
/** Built again after a relay of it reverted, which counts in `attempts` beside the prover's refusals: the request
|
|
106
|
+
* out may still land, so the step is not cancelled. */
|
|
107
|
+
sent?: true;
|
|
108
|
+
/** As a JoinSplit's, with `proved` where a JoinSplit is `attested`. */
|
|
107
109
|
relayedBlock?: number;
|
|
108
110
|
relayedAt?: number;
|
|
111
|
+
spentAt?: number;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
/** Where a send or withdrawal goes; `self` merges the wallet's own notes and nothing leaves. */
|
|
@@ -648,27 +651,72 @@ const positionsAt = (ctx: Ctx, chainId: bigint, index: number): Position[] =>
|
|
|
648
651
|
const positionOf = (ctx: Ctx, chainId: bigint, index: number): Position | undefined =>
|
|
649
652
|
positionsAt(ctx, chainId, index).find((f) => !f.tentative);
|
|
650
653
|
|
|
651
|
-
/** Whether the step's
|
|
652
|
-
* newer ones.
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
654
|
+
/** Whether any of the step's inputs is spent on chain: among the final nullifiers (as sync last saw them), or among
|
|
655
|
+
* the newer ones. Reads the pages everyone polls, never asks about a nullifier. */
|
|
656
|
+
async function inputsSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): Promise<boolean> {
|
|
657
|
+
const inputs = step.inputs.map((coin) => positionOf(ctx, op.chainId, coin.index));
|
|
658
|
+
if (inputs.some((f) => f?.spent)) return true;
|
|
659
|
+
// copies: sync alone moves the watches, over final entries
|
|
660
|
+
const watches = inputs.flatMap((f) => (f?.nullifier ? [{ ...f.nullifier }] : []));
|
|
661
|
+
if (watches.length === 0) return false;
|
|
658
662
|
const c = chainCtx(ctx, op.chainId);
|
|
659
663
|
const { nullifiers } = await c.indexer.status();
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
const hits = await scan([watch], 8, (p) => c.indexer.page('nullifiers', p), watch.scannedTo, nullifiers);
|
|
664
|
+
const from = Math.min(...watches.map((w) => w.scannedTo));
|
|
665
|
+
const hits = await scan(watches, 8, (p) => c.indexer.page('nullifiers', p), from, nullifiers);
|
|
663
666
|
return hits.length > 0;
|
|
664
667
|
}
|
|
665
668
|
|
|
666
|
-
/** Where a relayed
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
669
|
+
/** Where a relayed step stands, by its `change` (a merge's note) and its `inputs` as sync last saw them:
|
|
670
|
+
* - `landed`: the change is final where this wallet expects it;
|
|
671
|
+
* - `taken`: an input is spent and the final commitments passed `spentAt` without the change, so another transaction
|
|
672
|
+
* spent it, and this step can never land;
|
|
673
|
+
* - `dropped`: no input is spent and the final nullifiers passed `relayedAt` without them: whatever was spending them
|
|
674
|
+
* is gone, a reorg dropped it or the relayer never sent it;
|
|
675
|
+
* - `mined`: the change is in a block, not final yet;
|
|
676
|
+
* - `waiting`: none of these yet. */
|
|
677
|
+
export function relayedState(
|
|
678
|
+
change: Pick<NoteRecord, 'found' | 'watch'> | undefined,
|
|
679
|
+
inputs: (Position | undefined)[],
|
|
680
|
+
relayedAt: number | undefined,
|
|
681
|
+
spentAt: number | undefined,
|
|
682
|
+
): 'landed' | 'taken' | 'dropped' | 'mined' | 'waiting' {
|
|
683
|
+
const seen = change?.found.filter((f) => f.confirmed) ?? [];
|
|
684
|
+
if (seen.some((f) => !f.tentative)) return 'landed';
|
|
685
|
+
if (inputs.some((f) => f?.spent)) {
|
|
686
|
+
// a final position not read yet may be the change
|
|
687
|
+
const missed =
|
|
688
|
+
spentAt !== undefined &&
|
|
689
|
+
!!change?.watch &&
|
|
690
|
+
change.watch.scannedTo >= spentAt &&
|
|
691
|
+
!change.found.some((f) => !f.tentative);
|
|
692
|
+
if (missed) return 'taken';
|
|
693
|
+
} else {
|
|
694
|
+
const watches = inputs.flatMap((f) => (f?.nullifier ? [f.nullifier] : []));
|
|
695
|
+
if (relayedAt !== undefined && watches.length > 0 && watches.every((w) => w.scannedTo >= relayedAt))
|
|
696
|
+
return 'dropped';
|
|
697
|
+
}
|
|
698
|
+
return seen.length > 0 ? 'mined' : 'waiting';
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** `relayedState` of `step`, failing the operation once another transaction took an input. The first time an input
|
|
702
|
+
* is seen spent, short of the step landing, it notes the final commitment count: a later status counts no fewer, so
|
|
703
|
+
* once the change's watch passes it, it has looked wherever that transaction put its notes. */
|
|
704
|
+
async function standing(
|
|
705
|
+
ctx: Ctx,
|
|
706
|
+
op: SpendOperation,
|
|
707
|
+
step: Step | MergeStep,
|
|
708
|
+
): Promise<ReturnType<typeof relayedState>> {
|
|
709
|
+
const inputs = step.inputs.map((coin) => positionOf(ctx, op.chainId, coin.index));
|
|
710
|
+
const salt = 'output' in step ? step.output.salt : step.outputs[1].salt;
|
|
711
|
+
const change = ctx.state.notes.find((n) => n.chainId === op.chainId && n.salt === salt);
|
|
712
|
+
const now = relayedState(change, inputs, step.relayedAt, step.spentAt);
|
|
713
|
+
if (now === 'taken')
|
|
714
|
+
fail('spent-elsewhere', 'another device of this wallet spent a note or deposit of it first: it paid nothing');
|
|
715
|
+
if (now !== 'landed' && step.spentAt === undefined && inputs.some((f) => f?.spent)) {
|
|
716
|
+
step.spentAt = (await chainCtx(ctx, op.chainId).indexer.status()).commitmentsFinal;
|
|
717
|
+
await ctx.save();
|
|
718
|
+
}
|
|
719
|
+
return now;
|
|
672
720
|
}
|
|
673
721
|
|
|
674
722
|
/** The indexer's nullifier count once it is past `block`, so past the nullifiers of a transaction mined there unless a
|
|
@@ -688,6 +736,18 @@ export function landed(op: SpendOperation): boolean {
|
|
|
688
736
|
);
|
|
689
737
|
}
|
|
690
738
|
|
|
739
|
+
/** Whether `op` can still be cancelled, having paid nothing: a payment, withdrawal or consolidation under way with no
|
|
740
|
+
* request out to the relayer, and no merge of it landed. A step at its relay stage has one out, as its request leaves
|
|
741
|
+
* the moment it gets there; so has one built again after a relay of it reverted, as that request may still land: a
|
|
742
|
+
* JoinSplit's `attempts` count only those, and a merge, whose `attempts` count its prover's refusals too, is `sent`. */
|
|
743
|
+
export function cancellable(op: OperationRecord): boolean {
|
|
744
|
+
if (op.status !== 'pending' || op.kind === 'deposit' || op.data.paid > 0n) return false;
|
|
745
|
+
const step = op.data.step;
|
|
746
|
+
if (!step) return true;
|
|
747
|
+
if ('output' in step) return !step.sent && step.stage !== 'proved' && step.stage !== 'relayed';
|
|
748
|
+
return step.attempts === 0 && step.stage !== 'attested' && step.stage !== 'relayed';
|
|
749
|
+
}
|
|
750
|
+
|
|
691
751
|
function markSpent(ctx: Ctx, op: SpendOperation, step: { inputs: Coin[] }): void {
|
|
692
752
|
for (const coin of step.inputs) for (const f of positionsAt(ctx, op.chainId, coin.index)) f.spent = true;
|
|
693
753
|
}
|
|
@@ -700,14 +760,17 @@ function placeChange(ctx: Ctx, op: SpendOperation, salt: Hex, index: number | nu
|
|
|
700
760
|
}
|
|
701
761
|
}
|
|
702
762
|
|
|
703
|
-
/** Takes `op` as far as it can go now, saving before each step that leaves the device.
|
|
763
|
+
/** Takes `op` as far as it can go now, saving before each step that leaves the device. One cancelled meanwhile
|
|
764
|
+
* (`cancel`) finishes the stage under way and starts no other: its status is looked at after each, and nothing it does
|
|
765
|
+
* later is kept, as it is out of the state. A relayer that is relaying its request already throws `Relaying`. */
|
|
704
766
|
export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void> {
|
|
705
767
|
const data = op.data;
|
|
706
768
|
const c = chainCtx(ctx, op.chainId);
|
|
707
769
|
for (;;) {
|
|
770
|
+
if (op.status !== 'pending') return;
|
|
708
771
|
if (!data.step) {
|
|
709
772
|
const step = await planStep(ctx, op);
|
|
710
|
-
if (!step) return;
|
|
773
|
+
if (!step || op.status !== 'pending') return;
|
|
711
774
|
if (step === 'done') {
|
|
712
775
|
op.status = 'done';
|
|
713
776
|
await ctx.save();
|
|
@@ -726,6 +789,7 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
|
|
|
726
789
|
step.stage = 'noted';
|
|
727
790
|
await ctx.save();
|
|
728
791
|
}
|
|
792
|
+
if (op.status !== 'pending') return;
|
|
729
793
|
if (step.stage === 'noted') {
|
|
730
794
|
const built = await witnessOf(ctx, op, step);
|
|
731
795
|
if (!built) return;
|
|
@@ -771,6 +835,7 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
|
|
|
771
835
|
step.stage = 'proved';
|
|
772
836
|
await ctx.save();
|
|
773
837
|
}
|
|
838
|
+
if (op.status !== 'pending') return;
|
|
774
839
|
if (step.stage === 'proved') {
|
|
775
840
|
const attestation = await attest(c.config, step.statement!, step.witness!);
|
|
776
841
|
step.request = { ...step.request!, attestation };
|
|
@@ -778,21 +843,25 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
|
|
|
778
843
|
step.stage = 'attested';
|
|
779
844
|
await ctx.save();
|
|
780
845
|
}
|
|
846
|
+
if (op.status !== 'pending') return;
|
|
847
|
+
if (step.stage === 'attested' && step.relayedAt !== undefined) {
|
|
848
|
+
// a relay of it reverted on an input spent already: an earlier relay of it landed, one whose answer was lost, or
|
|
849
|
+
// another transaction spent the input. Relaying it again would tell nothing more
|
|
850
|
+
const now = await standing(ctx, op, step);
|
|
851
|
+
if (now === 'waiting') return;
|
|
852
|
+
if (now === 'dropped') delete step.relayedAt;
|
|
853
|
+
else step.stage = 'relayed';
|
|
854
|
+
await ctx.save();
|
|
855
|
+
}
|
|
781
856
|
if (step.stage === 'attested') {
|
|
782
857
|
const relayerOut = step.outputs.find((o) => o.role === 'relayer');
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
|
|
789
|
-
);
|
|
790
|
-
} catch (e) {
|
|
791
|
-
if (e instanceof RelayUnknown) return; // try the same request again next time
|
|
792
|
-
throw e;
|
|
793
|
-
}
|
|
858
|
+
const result = await relay(
|
|
859
|
+
c.config,
|
|
860
|
+
step.request!,
|
|
861
|
+
relayerOut ? { value: relayerOut.value, salt: relayerOut.salt } : undefined,
|
|
862
|
+
);
|
|
794
863
|
if ('reverted' in result) {
|
|
795
|
-
if (!(await
|
|
864
|
+
if (!(await inputsSpent(ctx, op, step))) {
|
|
796
865
|
// stale roots, most likely: prove again with the same outputs, next sync. A JoinSplit that keeps reverting
|
|
797
866
|
// is not on chain and cannot land, so failing it frees its inputs.
|
|
798
867
|
if (++step.attempts > 3)
|
|
@@ -804,23 +873,24 @@ export async function advanceSpend(ctx: Ctx, op: SpendOperation): Promise<void>
|
|
|
804
873
|
await ctx.save();
|
|
805
874
|
return;
|
|
806
875
|
}
|
|
807
|
-
//
|
|
876
|
+
// the indexer counts the nullifiers of whatever spent it already
|
|
808
877
|
step.relayedAt = (await c.indexer.status()).nullifiers;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
data.txHashes = [...(data.txHashes ?? []), result.txHash];
|
|
812
|
-
step.relayedBlock = result.block;
|
|
813
|
-
delete step.relayedAt;
|
|
878
|
+
await ctx.save();
|
|
879
|
+
return;
|
|
814
880
|
}
|
|
881
|
+
placeChange(ctx, op, step.outputs[1].salt, result.noteIndex2);
|
|
882
|
+
data.txHashes = [...(data.txHashes ?? []), result.txHash];
|
|
883
|
+
step.relayedBlock = result.block;
|
|
815
884
|
step.stage = 'relayed';
|
|
816
885
|
await ctx.save();
|
|
817
886
|
}
|
|
818
887
|
if (step.stage === 'relayed') {
|
|
819
888
|
step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
|
|
820
|
-
const now =
|
|
889
|
+
const now = await standing(ctx, op, step);
|
|
821
890
|
if (now === 'dropped') {
|
|
822
891
|
// relay it again next sync; if it did land after all, that reverts and finds it
|
|
823
892
|
step.stage = 'attested';
|
|
893
|
+
delete step.relayedAt;
|
|
824
894
|
await ctx.save();
|
|
825
895
|
}
|
|
826
896
|
if (now !== 'landed') return;
|
|
@@ -853,6 +923,7 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
|
|
|
853
923
|
step.stage = 'noted';
|
|
854
924
|
await ctx.save();
|
|
855
925
|
}
|
|
926
|
+
if (op.status !== 'pending') return false;
|
|
856
927
|
if (step.stage === 'noted') {
|
|
857
928
|
const built = await mergeWitnessOf(ctx, op, step);
|
|
858
929
|
if (!built) return false;
|
|
@@ -888,6 +959,7 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
|
|
|
888
959
|
step.stage = 'attested';
|
|
889
960
|
await ctx.save();
|
|
890
961
|
}
|
|
962
|
+
if (op.status !== 'pending') return false;
|
|
891
963
|
if (step.stage === 'attested') {
|
|
892
964
|
await requestProof(c.config, prover, step.statement!, step.witness!, step.inputs.length);
|
|
893
965
|
delete step.witness; // the prover has it: the inputs' openings need not stay saved here
|
|
@@ -909,33 +981,39 @@ async function advanceMerge(ctx: Ctx, op: SpendOperation, step: MergeStep): Prom
|
|
|
909
981
|
step.stage = 'proved';
|
|
910
982
|
await ctx.save();
|
|
911
983
|
}
|
|
984
|
+
if (op.status !== 'pending') return false;
|
|
985
|
+
if (step.stage === 'proved' && step.relayedAt !== undefined) {
|
|
986
|
+
// as a JoinSplit's, told apart by its note
|
|
987
|
+
const now = await standing(ctx, op, step);
|
|
988
|
+
if (now === 'waiting') return false;
|
|
989
|
+
if (now === 'dropped') delete step.relayedAt;
|
|
990
|
+
else step.stage = 'relayed';
|
|
991
|
+
await ctx.save();
|
|
992
|
+
}
|
|
912
993
|
if (step.stage === 'proved') {
|
|
913
|
-
|
|
914
|
-
try {
|
|
915
|
-
result = await relayMerge(c.config, step.request!);
|
|
916
|
-
} catch (e) {
|
|
917
|
-
if (e instanceof RelayUnknown) return false; // try the same request again next time
|
|
918
|
-
throw e;
|
|
919
|
-
}
|
|
994
|
+
const result = await relayMerge(c.config, step.request!);
|
|
920
995
|
if ('reverted' in result) {
|
|
921
996
|
// stale roots, most likely: built again with the same output, next sync
|
|
922
|
-
if (!(await
|
|
923
|
-
|
|
997
|
+
if (!(await inputsSpent(ctx, op, step))) {
|
|
998
|
+
step.sent = true;
|
|
999
|
+
return again(result.reverted);
|
|
1000
|
+
}
|
|
924
1001
|
step.relayedAt = (await c.indexer.status()).nullifiers;
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
data.txHashes = [...(data.txHashes ?? []), result.txHash];
|
|
928
|
-
step.relayedBlock = result.block;
|
|
929
|
-
delete step.relayedAt;
|
|
1002
|
+
await ctx.save();
|
|
1003
|
+
return false;
|
|
930
1004
|
}
|
|
1005
|
+
placeChange(ctx, op, step.output.salt, result.noteIndex);
|
|
1006
|
+
data.txHashes = [...(data.txHashes ?? []), result.txHash];
|
|
1007
|
+
step.relayedBlock = result.block;
|
|
931
1008
|
step.stage = 'relayed';
|
|
932
1009
|
await ctx.save();
|
|
933
1010
|
}
|
|
934
1011
|
step.relayedAt ??= await nullifiersPast(c.indexer, step.relayedBlock!);
|
|
935
|
-
const now =
|
|
1012
|
+
const now = await standing(ctx, op, step);
|
|
936
1013
|
if (now === 'dropped') {
|
|
937
1014
|
// relay it again next sync; if it did land after all, that reverts and finds it
|
|
938
1015
|
step.stage = 'proved';
|
|
1016
|
+
delete step.relayedAt;
|
|
939
1017
|
await ctx.save();
|
|
940
1018
|
}
|
|
941
1019
|
if (now !== 'landed') return false;
|