@sorandomains/holder 0.3.1 → 0.5.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/README.md +43 -9
- package/dist/index.d.ts +40 -5
- package/dist/index.js +93 -14
- package/dist/native-allowlist.d.ts +21 -0
- package/dist/native-allowlist.js +34 -0
- package/dist/native-approver.d.ts +21 -0
- package/dist/native-approver.js +36 -0
- package/dist/native-auth.d.ts +31 -0
- package/dist/native-auth.js +85 -0
- package/dist/native-codec.d.ts +38 -0
- package/dist/native-codec.js +95 -0
- package/dist/native-holder.d.ts +42 -0
- package/dist/native-holder.js +221 -0
- package/dist/native-transport.d.ts +66 -0
- package/dist/native-transport.js +136 -0
- package/dist/native-types.d.ts +166 -0
- package/dist/native-types.js +98 -0
- package/dist/payment.d.ts +10 -1
- package/dist/payment.js +57 -4
- package/package.json +2 -2
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Local successor-ABI helpers. No deployed defaults are changed by this module. */
|
|
2
|
+
import { xdr } from "@stellar/stellar-sdk";
|
|
3
|
+
import { type PaymentDestination } from "./payment.js";
|
|
4
|
+
export declare const NATIVE_REGISTRAR_ERRORS: Record<number, string>;
|
|
5
|
+
export declare class NativeClaimError extends Error {
|
|
6
|
+
readonly kind: "unsupported" | "unavailable" | "invalid" | "authorization" | "pending" | "failed";
|
|
7
|
+
readonly txHash: string | null;
|
|
8
|
+
readonly contractCode: number | null;
|
|
9
|
+
readonly contractError: string | null;
|
|
10
|
+
constructor(message: string, kind?: "unsupported" | "unavailable" | "invalid" | "authorization" | "pending" | "failed", txHash?: string | null);
|
|
11
|
+
}
|
|
12
|
+
export declare function exactObject(raw: unknown, keys: readonly string[], label: string): Record<string, unknown>;
|
|
13
|
+
export declare function u64(value: unknown, label: string): bigint;
|
|
14
|
+
export declare function u32(value: unknown, label: string): number;
|
|
15
|
+
export declare function amount(value: unknown, label: string): bigint;
|
|
16
|
+
export declare function bool(value: unknown, label: string): boolean;
|
|
17
|
+
export declare function address(value: unknown, kind: "account" | "contract" | "identity", label: string): string;
|
|
18
|
+
export declare function bytes32(value: unknown, label: string): Uint8Array;
|
|
19
|
+
export declare function hex32(value: unknown, label: string): string;
|
|
20
|
+
export declare const hex: (value: Uint8Array) => string;
|
|
21
|
+
export declare const unhex: (value: string) => Uint8Array;
|
|
22
|
+
export declare const utf8: (value: string) => Uint8Array;
|
|
23
|
+
export declare function label(value: unknown): string;
|
|
24
|
+
export declare function namespaceNode(namespace: string): Uint8Array;
|
|
25
|
+
export declare const sc: {
|
|
26
|
+
address: (value: string) => xdr.ScVal;
|
|
27
|
+
bytes: (value: Uint8Array) => xdr.ScVal;
|
|
28
|
+
u64: (value: bigint) => xdr.ScVal;
|
|
29
|
+
u32: (value: number) => xdr.ScVal;
|
|
30
|
+
i128: (value: bigint) => xdr.ScVal;
|
|
31
|
+
bool: (value: boolean) => xdr.ScVal;
|
|
32
|
+
symbol: (value: string) => xdr.ScVal;
|
|
33
|
+
option: (value: xdr.ScVal | null) => xdr.ScVal;
|
|
34
|
+
};
|
|
35
|
+
/** Contract structs are canonical symbol-keyed maps, not generic JSON maps. */
|
|
36
|
+
export declare function struct(fields: Record<string, xdr.ScVal>): xdr.ScVal;
|
|
37
|
+
export declare function paymentDestinationToScVal(raw: PaymentDestination): xdr.ScVal;
|
|
38
|
+
export declare function sameScVal(left: xdr.ScVal, right: xdr.ScVal): boolean;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Local successor-ABI helpers. No deployed defaults are changed by this module. */
|
|
2
|
+
import { Address, StrKey, hash, nativeToScVal, xdr } from "@stellar/stellar-sdk";
|
|
3
|
+
import { decodeMuxedAddress, paymentMemoToScVal, validatePaymentDestination } from "./payment.js";
|
|
4
|
+
export const NATIVE_REGISTRAR_ERRORS = { 21: "InvalidClaimConfig", 22: "ClaimsNotConfigured", 23: "ClaimsPaused", 24: "StaleClaimPolicy", 25: "ClaimIntentMismatch", 26: "ClaimIntentExpired", 27: "ReservedName", 28: "NameNotReserved", 29: "PublicIssuanceRequired", 30: "WalletClaimLimit", 31: "InvalidEligibility", 32: "ApprovalAllowanceReached", 33: "ApprovalRateReached", 34: "RequestIdConflict", 35: "CounterOverflow", 36: "InvalidNativeBinding", 37: "UnsupportedClaimant", 38: "DuplicateLabel", 39: "RenewalTooEarly", 40: "RenewalLeaseLimit", 41: "DestinationInitializationFailed", 42: "FeeSettlementFailed" };
|
|
5
|
+
export class NativeClaimError extends Error {
|
|
6
|
+
kind;
|
|
7
|
+
txHash;
|
|
8
|
+
contractCode;
|
|
9
|
+
contractError;
|
|
10
|
+
constructor(message, kind = "invalid", txHash = null) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
this.txHash = txHash;
|
|
14
|
+
this.name = "NativeClaimError";
|
|
15
|
+
const match = /Error\(Contract, #(\d+)\)/.exec(message);
|
|
16
|
+
this.contractCode = match ? Number(match[1]) : null;
|
|
17
|
+
this.contractError = this.contractCode === null ? null : NATIVE_REGISTRAR_ERRORS[this.contractCode] ?? null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function exactObject(raw, keys, label) {
|
|
21
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || Object.keys(raw).sort().join(",") !== [...keys].sort().join(","))
|
|
22
|
+
throw new NativeClaimError(`invalid ${label} fields`);
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
|
+
export function u64(value, label) {
|
|
26
|
+
if (typeof value !== "bigint" || value < 0n || value > 18446744073709551615n)
|
|
27
|
+
throw new NativeClaimError(`${label} must be an unsigned 64-bit bigint`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export function u32(value, label) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 4294967295)
|
|
32
|
+
throw new NativeClaimError(`${label} must be an unsigned 32-bit number`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
export function amount(value, label) {
|
|
36
|
+
if (typeof value !== "bigint" || value < 0n || value > (1n << 127n) - 1n)
|
|
37
|
+
throw new NativeClaimError(`${label} must be a nonnegative i128 bigint`);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
export function bool(value, label) {
|
|
41
|
+
if (typeof value !== "boolean")
|
|
42
|
+
throw new NativeClaimError(`${label} must be boolean`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
export function address(value, kind, label) {
|
|
46
|
+
if (typeof value !== "string" || !(kind !== "contract" && StrKey.isValidEd25519PublicKey(value) || kind !== "account" && StrKey.isValidContract(value)))
|
|
47
|
+
throw new NativeClaimError(`${label} must be a valid ${kind === "identity" ? "G or C" : kind === "account" ? "G" : "C"} address`);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
export function bytes32(value, label) {
|
|
51
|
+
if (!(value instanceof Uint8Array) || value.length !== 32)
|
|
52
|
+
throw new NativeClaimError(`${label} must contain exactly 32 bytes`);
|
|
53
|
+
return new Uint8Array(value);
|
|
54
|
+
}
|
|
55
|
+
export function hex32(value, label) {
|
|
56
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value))
|
|
57
|
+
throw new NativeClaimError(`${label} must be 64 lowercase hex characters`);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
export const hex = (value) => Array.from(value, byte => byte.toString(16).padStart(2, "0")).join("");
|
|
61
|
+
export const unhex = (value) => Uint8Array.from(hex32(value, "32-byte value").match(/../g), byte => parseInt(byte, 16));
|
|
62
|
+
export const utf8 = (value) => new TextEncoder().encode(value);
|
|
63
|
+
export function label(value) {
|
|
64
|
+
if (typeof value !== "string" || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(value))
|
|
65
|
+
throw new NativeClaimError("intent label must be canonical lowercase ASCII, 1–63 characters");
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
export function namespaceNode(namespace) {
|
|
69
|
+
const combined = new Uint8Array(64);
|
|
70
|
+
combined.set(hash(utf8(label(namespace))), 32);
|
|
71
|
+
return new Uint8Array(hash(combined));
|
|
72
|
+
}
|
|
73
|
+
export const sc = {
|
|
74
|
+
address: (value) => new Address(value).toScVal(),
|
|
75
|
+
bytes: (value) => nativeToScVal(value, { type: "bytes" }),
|
|
76
|
+
u64: (value) => nativeToScVal(value, { type: "u64" }),
|
|
77
|
+
u32: (value) => nativeToScVal(value, { type: "u32" }),
|
|
78
|
+
i128: (value) => nativeToScVal(value, { type: "i128" }),
|
|
79
|
+
bool: (value) => nativeToScVal(value, { type: "bool" }),
|
|
80
|
+
symbol: (value) => nativeToScVal(value, { type: "symbol" }),
|
|
81
|
+
option: (value) => value ?? xdr.ScVal.scvVoid(),
|
|
82
|
+
};
|
|
83
|
+
/** Contract structs are canonical symbol-keyed maps, not generic JSON maps. */
|
|
84
|
+
export function struct(fields) {
|
|
85
|
+
return xdr.ScVal.scvMap(Object.keys(fields).sort().map(key => new xdr.ScMapEntry({ key: sc.symbol(key), val: fields[key] })));
|
|
86
|
+
}
|
|
87
|
+
export function paymentDestinationToScVal(raw) {
|
|
88
|
+
const payment = validatePaymentDestination(raw);
|
|
89
|
+
if (StrKey.isValidMed25519PublicKey(payment.address)) {
|
|
90
|
+
const value = decodeMuxedAddress(payment.address);
|
|
91
|
+
return xdr.ScVal.scvVec([sc.symbol("Muxed"), struct({ account: sc.address(value.account), id: sc.u64(BigInt(value.id)) })]);
|
|
92
|
+
}
|
|
93
|
+
return xdr.ScVal.scvVec([sc.symbol("Direct"), struct({ address: sc.address(payment.address), memo: paymentMemoToScVal(payment.memo) })]);
|
|
94
|
+
}
|
|
95
|
+
export function sameScVal(left, right) { return left.toXDR("base64") === right.toXDR("base64"); }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type NativeAuthorizationPlan } from "./native-auth.js";
|
|
2
|
+
import { type NativeContext, type NativeWriteOptions, type NativePrepared } from "./native-transport.js";
|
|
3
|
+
import { type ClaimIntent, type ClaimQuote, type ClaimReceipt, type ClaimResult, type TransferIntent, type RenewIntent, type DestinationPreview } from "./native-types.js";
|
|
4
|
+
import { type PaymentDestination } from "./payment.js";
|
|
5
|
+
export type ClaimRequestOptions = {
|
|
6
|
+
requestId: string;
|
|
7
|
+
deadline: bigint;
|
|
8
|
+
validAfter?: bigint;
|
|
9
|
+
};
|
|
10
|
+
export type ClaimSubmitOptions = NativeWriteOptions & {
|
|
11
|
+
proof?: readonly string[];
|
|
12
|
+
};
|
|
13
|
+
export type NativeClaimSubmission = ClaimResult & {
|
|
14
|
+
transaction: {
|
|
15
|
+
hash: string;
|
|
16
|
+
ledger: number;
|
|
17
|
+
} | null;
|
|
18
|
+
};
|
|
19
|
+
export type PreparedClaim = NativePrepared & {
|
|
20
|
+
intent: ClaimIntent;
|
|
21
|
+
plan: NativeAuthorizationPlan;
|
|
22
|
+
};
|
|
23
|
+
export declare function createClaimIntent(quote: ClaimQuote, destination: PaymentDestination, options: ClaimRequestOptions): ClaimIntent;
|
|
24
|
+
export declare class NativeHolderClient {
|
|
25
|
+
private context;
|
|
26
|
+
constructor(context: NativeContext);
|
|
27
|
+
nativeClaimCapability(namespace: string): Promise<import("./native-transport.js").NativeCapability>;
|
|
28
|
+
private registrar;
|
|
29
|
+
private checkRegistrar;
|
|
30
|
+
claimQuote(name: string, claimant?: string): Promise<ClaimQuote>;
|
|
31
|
+
private checkQuote;
|
|
32
|
+
claimReceipt(namespace: string, claimant: string, requestId: string): Promise<ClaimReceipt | null>;
|
|
33
|
+
private receiptAt;
|
|
34
|
+
recoverClaim(intent: ClaimIntent): Promise<ClaimReceipt | null>;
|
|
35
|
+
private planClaim;
|
|
36
|
+
buildClaim(intent: ClaimIntent, options?: ClaimSubmitOptions): Promise<PreparedClaim>;
|
|
37
|
+
claim(intent: ClaimIntent, options?: ClaimSubmitOptions): Promise<NativeClaimSubmission>;
|
|
38
|
+
renewalPreview(name: string): Promise<DestinationPreview>;
|
|
39
|
+
acceptNameTransferWithDestination(input: TransferIntent, options?: NativeWriteOptions): Promise<NativeClaimSubmission>;
|
|
40
|
+
renewName(input: RenewIntent, options?: NativeWriteOptions): Promise<NativeClaimSubmission>;
|
|
41
|
+
private lifecycle;
|
|
42
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { hash, scValToNative, xdr } from "@stellar/stellar-sdk";
|
|
2
|
+
import { NativeClaimError, address, bytes32, exactObject, hex, hex32, label, namespaceNode, sc, u64, unhex, utf8 } from "./native-codec.js";
|
|
3
|
+
import { nativeCapability, requireNative, verifyRegistrarProvenance, prepareNative, sendNative } from "./native-transport.js";
|
|
4
|
+
import { claimIntentToScVal, claimIntentFromNative, claimQuoteFromNative, claimReceiptFromNative, claimResultFromNative, destinationPreviewFromNative, transferIntentToScVal, renewIntentToScVal, nativeIntentHash, claimLabelToScVal } from "./native-types.js";
|
|
5
|
+
import { paymentDestinationToScVal } from "./native-codec.js";
|
|
6
|
+
import { validatePaymentDestination } from "./payment.js";
|
|
7
|
+
import { verifyClaimAllowlistProof } from "./native-allowlist.js";
|
|
8
|
+
const canonicalNamespace = (value) => { if (typeof value !== "string" || /[^\x00-\x7f]/.test(value))
|
|
9
|
+
throw new NativeClaimError("namespace must contain ASCII characters only"); return label(value.toLowerCase()); };
|
|
10
|
+
const names = (name) => { if (typeof name !== "string" || /[^\x00-\x7f]/.test(name))
|
|
11
|
+
throw new NativeClaimError("name must be ASCII"); const parts = name.toLowerCase().split("."); if (parts.length !== 2)
|
|
12
|
+
throw new NativeClaimError("expected label.namespace"); return { label: label(parts[0]), namespace: label(parts[1]) }; };
|
|
13
|
+
const networkId = (passphrase) => hex(hash(utf8(passphrase)));
|
|
14
|
+
function scoped(context, request) { if (request.registry !== context.registryId || request.network !== networkId(context.passphrase))
|
|
15
|
+
throw new NativeClaimError("intent targets a different network or Registry"); }
|
|
16
|
+
function contextFromQuote(quote, opts) { return { network: quote.network, registry: quote.registry, registrar: quote.registrar, namespace: quote.namespace, requestId: hex32(opts.requestId, "request ID"), validAfter: opts.validAfter ?? quote.now, deadline: opts.deadline }; }
|
|
17
|
+
export function createClaimIntent(quote, destination, options) {
|
|
18
|
+
if (!quote.config)
|
|
19
|
+
throw new NativeClaimError("namespace public claims are not configured", "unsupported");
|
|
20
|
+
const config = quote.config;
|
|
21
|
+
const settings = config.settings;
|
|
22
|
+
const result = { context: contextFromQuote(quote, options), label: quote.label, claimant: quote.claimant, destination: validatePaymentDestination(destination), resolver: quote.resolver, expectedGeneration: quote.generation, expectedExpiry: quote.expiresAt, termSecs: quote.termSecs, ownerEpoch: quote.ownerEpoch, policyVersion: quote.policyVersion, grantEpoch: config.grantEpoch, feeToken: settings.feeToken, feeAmount: settings.feeAmount, feeRecipient: settings.feeRecipient };
|
|
23
|
+
return claimIntentFromNative(scValToNative(claimIntentToScVal(result)));
|
|
24
|
+
}
|
|
25
|
+
function initialization(resolver, nameLabel, holder, generation, destination) { return { contract: resolver, method: "initialize_destination", args: [claimLabelToScVal(nameLabel), sc.address(holder), sc.u64(u64(generation, "new generation")), paymentDestinationToScVal(destination)] }; }
|
|
26
|
+
function receiptMatches(receipt, method, intent, holder) {
|
|
27
|
+
if (receipt.intentHash !== nativeIntentHash(method, intent) || receipt.holder !== holder)
|
|
28
|
+
throw new NativeClaimError("request ID already belongs to a different immutable intent");
|
|
29
|
+
const raw = scValToNative(intent), expectedOperation = { claim: "claim", issue_reserved_with_destination: "reserved", accept_transfer_with_destination: "transfer", renew_holder: "renew" }[method];
|
|
30
|
+
const nodeBytes = new Uint8Array(64);
|
|
31
|
+
nodeBytes.set(raw.context.namespace);
|
|
32
|
+
nodeBytes.set(hash(raw.label), 32);
|
|
33
|
+
if (receipt.operation !== expectedOperation || receipt.node !== hex(hash(nodeBytes)))
|
|
34
|
+
throw new NativeClaimError("receipt operation/name differs from intent");
|
|
35
|
+
const generation = method === "renew_holder" ? raw.expected_generation : raw.expected_generation === null || raw.expected_generation === undefined ? 0n : raw.expected_generation + 1n;
|
|
36
|
+
if (receipt.generation !== generation)
|
|
37
|
+
throw new NativeClaimError("receipt generation differs from intent");
|
|
38
|
+
if (method === "claim") {
|
|
39
|
+
if (receipt.feeAmount !== raw.fee_amount || receipt.feeToken !== raw.fee_token || receipt.feeRecipient !== (raw.fee_amount > 0n ? raw.fee_recipient : null))
|
|
40
|
+
throw new NativeClaimError("receipt fee settlement differs from intent");
|
|
41
|
+
if (receipt.expiresAt !== (raw.term_secs === 0n ? 0n : receipt.timestamp + raw.term_secs))
|
|
42
|
+
throw new NativeClaimError("receipt lease differs from intent");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
if (receipt.feeAmount !== 0n || receipt.feeRecipient !== null)
|
|
46
|
+
throw new NativeClaimError("lifecycle receipt unexpectedly contains a username fee");
|
|
47
|
+
if (method === "accept_transfer_with_destination" && receipt.expiresAt !== raw.expected_expiry)
|
|
48
|
+
throw new NativeClaimError("transfer receipt changed the lease");
|
|
49
|
+
if (method === "renew_holder" && (receipt.expiresAt < raw.min_new_expiry || receipt.expiresAt > raw.max_new_expiry || receipt.expiresAt !== (receipt.timestamp > raw.expected_expiry ? receipt.timestamp : raw.expected_expiry) + raw.term_secs))
|
|
50
|
+
throw new NativeClaimError("renewal receipt differs from signed lease bounds");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function confirmedResult(result, method, encoded, holder) { try {
|
|
54
|
+
const decoded = claimResultFromNative(result.value);
|
|
55
|
+
receiptMatches(decoded.receipt, method, encoded, holder);
|
|
56
|
+
return { ...decoded, transaction: { hash: result.hash, ledger: result.ledger } };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new NativeClaimError(`transaction confirmed but result could not be verified: ${String(error)}; recover the original request`, "pending", result.hash);
|
|
60
|
+
} }
|
|
61
|
+
export class NativeHolderClient {
|
|
62
|
+
context;
|
|
63
|
+
constructor(context) {
|
|
64
|
+
this.context = context;
|
|
65
|
+
}
|
|
66
|
+
async nativeClaimCapability(namespace) { return nativeCapability(this.context, canonicalNamespace(namespace)); }
|
|
67
|
+
async registrar(namespace) {
|
|
68
|
+
const node = namespaceNode(namespace);
|
|
69
|
+
const registrar = address(await this.context.read(this.context.registryId, "registrar_of", [sc.bytes(node)]), "contract", "Registrar");
|
|
70
|
+
await this.checkRegistrar(registrar, hex(node));
|
|
71
|
+
return registrar;
|
|
72
|
+
}
|
|
73
|
+
async checkRegistrar(registrar, node) {
|
|
74
|
+
await verifyRegistrarProvenance(this.context, registrar, node);
|
|
75
|
+
const [anchors, version] = await Promise.all([this.context.read(registrar, "anchors", []), this.context.read(registrar, "claim_version", [])]);
|
|
76
|
+
const attested = await this.context.read(this.context.registryId, "registrar_of", [sc.bytes(unhex(node))]);
|
|
77
|
+
if (attested !== registrar)
|
|
78
|
+
throw new NativeClaimError("intent Registrar is not the Registry attestation", "unavailable");
|
|
79
|
+
if (version !== 1)
|
|
80
|
+
throw new NativeClaimError("unsupported native claim version", "unsupported");
|
|
81
|
+
if (!Array.isArray(anchors) || anchors.length !== 2 || anchors[0] !== this.context.registryId || hex(bytes32(anchors[1], "namespace anchor")) !== node)
|
|
82
|
+
throw new NativeClaimError("Registrar anchors differ from intent", "unavailable");
|
|
83
|
+
}
|
|
84
|
+
async claimQuote(name, claimant) {
|
|
85
|
+
const parsed = names(name);
|
|
86
|
+
const cap = await requireNative(this.context, parsed.namespace);
|
|
87
|
+
const wallet = address(claimant ?? await this.context.signer.publicKey(), "account", "claimant");
|
|
88
|
+
const quote = claimQuoteFromNative(await this.context.read(cap.registrar, "claim_quote", [claimLabelToScVal(parsed.label), sc.address(wallet)]));
|
|
89
|
+
this.checkQuote(quote, cap.registrar, hex(namespaceNode(parsed.namespace)), parsed.label, wallet);
|
|
90
|
+
if (quote.resolver !== cap.resolver)
|
|
91
|
+
throw new NativeClaimError("quote Resolver differs from the selected native binding", "unavailable");
|
|
92
|
+
return quote;
|
|
93
|
+
}
|
|
94
|
+
checkQuote(quote, registrar, node, nameLabel, wallet) {
|
|
95
|
+
if (quote.network !== networkId(this.context.passphrase) || quote.registry !== this.context.registryId || quote.registrar !== registrar || quote.namespace !== node || quote.label !== nameLabel || quote.claimant !== wallet)
|
|
96
|
+
throw new NativeClaimError("claim quote differs from requested network, namespace or claimant", "unavailable");
|
|
97
|
+
const bytes = new Uint8Array(64);
|
|
98
|
+
bytes.set(unhex(node));
|
|
99
|
+
bytes.set(hash(utf8(nameLabel)), 32);
|
|
100
|
+
if (quote.node !== hex(hash(bytes)))
|
|
101
|
+
throw new NativeClaimError("quote name node is invalid", "unavailable");
|
|
102
|
+
}
|
|
103
|
+
async claimReceipt(namespace, claimant, requestId) { return this.receiptAt(await this.registrar(canonicalNamespace(namespace)), claimant, requestId); }
|
|
104
|
+
async receiptAt(registrar, claimant, requestId) {
|
|
105
|
+
const raw = await this.context.read(registrar, "claim_receipt", [sc.address(address(claimant, "identity", "receipt holder")), sc.bytes(unhex(requestId))]);
|
|
106
|
+
return raw === null ? null : claimReceiptFromNative(raw);
|
|
107
|
+
}
|
|
108
|
+
async recoverClaim(intent) { const encoded = claimIntentToScVal(intent); scoped(this.context, intent.context); await verifyRegistrarProvenance(this.context, intent.context.registrar, intent.context.namespace); if (await this.context.read(this.context.registryId, "registrar_of", [sc.bytes(unhex(intent.context.namespace))]) !== intent.context.registrar)
|
|
109
|
+
throw new NativeClaimError("recovery Registrar is not the Registry attestation", "unavailable"); const receipt = await this.receiptAt(intent.context.registrar, intent.claimant, intent.context.requestId); if (receipt)
|
|
110
|
+
receiptMatches(receipt, "claim", encoded, intent.claimant); return receipt; }
|
|
111
|
+
async planClaim(input, proof = []) {
|
|
112
|
+
const encoded = claimIntentToScVal(input);
|
|
113
|
+
const intent = claimIntentFromNative(scValToNative(encoded));
|
|
114
|
+
scoped(this.context, intent.context);
|
|
115
|
+
const wallet = address(await this.context.signer.publicKey(), "account", "claimant");
|
|
116
|
+
if (intent.claimant !== wallet)
|
|
117
|
+
throw new NativeClaimError("intent claimant is not the connected wallet", "authorization");
|
|
118
|
+
await this.checkRegistrar(intent.context.registrar, intent.context.namespace);
|
|
119
|
+
const pair = await this.context.read(this.context.registryId, "native_contracts", [sc.bytes(unhex(intent.context.namespace))]);
|
|
120
|
+
if (!Array.isArray(pair) || pair.length !== 2 || pair[0] !== intent.context.registrar || pair[1] !== intent.resolver)
|
|
121
|
+
throw new NativeClaimError("intent differs from current clean native contract bindings", "unavailable");
|
|
122
|
+
const quote = claimQuoteFromNative(await this.context.read(intent.context.registrar, "claim_quote", [claimLabelToScVal(intent.label), sc.address(wallet)]));
|
|
123
|
+
this.checkQuote(quote, intent.context.registrar, intent.context.namespace, intent.label, wallet);
|
|
124
|
+
const config = quote.config;
|
|
125
|
+
if (!config || config.settings.mode !== "public" || !config.settings.enabled)
|
|
126
|
+
throw new NativeClaimError("public claiming is disabled", "unavailable");
|
|
127
|
+
if (!quote.available || quote.reserved)
|
|
128
|
+
throw new NativeClaimError(quote.reserved ? "username is reserved" : "username is unavailable");
|
|
129
|
+
if (quote.now < intent.context.validAfter || quote.now > intent.context.deadline)
|
|
130
|
+
throw new NativeClaimError("claim intent is outside its business deadline");
|
|
131
|
+
if (config.ownerEpoch !== quote.ownerEpoch || config.policyVersion !== quote.policyVersion || intent.ownerEpoch !== quote.ownerEpoch || intent.policyVersion !== quote.policyVersion || intent.grantEpoch !== config.grantEpoch || intent.resolver !== quote.resolver || intent.expectedGeneration !== quote.generation || intent.expectedExpiry !== quote.expiresAt || intent.termSecs !== quote.termSecs || intent.feeToken !== config.settings.feeToken || intent.feeAmount !== config.settings.feeAmount || intent.feeRecipient !== config.settings.feeRecipient)
|
|
132
|
+
throw new NativeClaimError("claim terms changed; reconcile the original request before reviewing a replacement");
|
|
133
|
+
const nativeToken = await this.context.read(intent.context.registrar, "native_fee_token", []);
|
|
134
|
+
if (nativeToken !== intent.feeToken)
|
|
135
|
+
throw new NativeClaimError("claim fee is not the canonical native token");
|
|
136
|
+
if (config.settings.walletLimit > 0n && quote.usage.publicClaims >= config.settings.walletLimit)
|
|
137
|
+
throw new NativeClaimError("lifetime wallet claim limit reached");
|
|
138
|
+
const admission = config.settings.admission;
|
|
139
|
+
if (!Array.isArray(proof) || proof.length > 32)
|
|
140
|
+
throw new NativeClaimError("allowlist proof exceeds 32 siblings");
|
|
141
|
+
if (admission.type === "allowlist") {
|
|
142
|
+
if (!verifyClaimAllowlistProof({ network: intent.context.network, registry: intent.context.registry, namespace: intent.context.namespace, registrar: intent.context.registrar }, wallet, proof, admission.root))
|
|
143
|
+
throw new NativeClaimError("wallet is not in the committed allowlist");
|
|
144
|
+
}
|
|
145
|
+
else if (proof.length)
|
|
146
|
+
throw new NativeClaimError("this admission mode does not accept an allowlist proof");
|
|
147
|
+
const children = [];
|
|
148
|
+
if (intent.feeAmount > 0n)
|
|
149
|
+
children.push({ contract: intent.feeToken, method: "transfer", args: [sc.address(wallet), sc.address(intent.feeRecipient), sc.i128(intent.feeAmount)] });
|
|
150
|
+
children.push(initialization(intent.resolver, intent.label, wallet, intent.expectedGeneration === null ? 0n : intent.expectedGeneration + 1n, intent.destination));
|
|
151
|
+
const plan = { source: wallet, contract: intent.context.registrar, method: "claim", args: [encoded, xdr.ScVal.scvVec(proof.map(value => sc.bytes(unhex(value))))], sourceInvocation: { contract: intent.context.registrar, method: "claim", args: [encoded], children }, maxFeeStroops: this.context.maxFeeStroops };
|
|
152
|
+
if (admission.type === "approval") {
|
|
153
|
+
if (admission.account === wallet || admission.account === intent.feeRecipient)
|
|
154
|
+
throw new NativeClaimError("eligibility account must be separate from claimant and treasury", "authorization");
|
|
155
|
+
if (intent.context.deadline - intent.context.validAfter > config.settings.approvalTtlSecs)
|
|
156
|
+
throw new NativeClaimError("claim lifetime exceeds the current approval lifetime");
|
|
157
|
+
if (quote.approvalUsage.total >= config.settings.approvalAllowance)
|
|
158
|
+
throw new NativeClaimError("cumulative approval allowance exhausted");
|
|
159
|
+
if (quote.now < quote.approvalUsage.windowEnds && quote.approvalUsage.windowUsed >= config.settings.approvalRateLimit)
|
|
160
|
+
throw new NativeClaimError("approval rate limit reached");
|
|
161
|
+
const latest = await this.context.server.getLatestLedger();
|
|
162
|
+
// Ledger credential TTL and timestamp business TTL are separate ceilings. At most 60 ledgers per SDK approval.
|
|
163
|
+
const remaining = intent.context.deadline - quote.now;
|
|
164
|
+
const ledgerSpan = Math.max(1, Math.min(60, Number(remaining)));
|
|
165
|
+
plan.eligibility = { account: admission.account, invocation: { contract: intent.context.registrar, method: "claim", args: [encoded] }, latestLedger: latest.sequence, maxExpirationLedger: latest.sequence + ledgerSpan };
|
|
166
|
+
}
|
|
167
|
+
return { intent, plan };
|
|
168
|
+
}
|
|
169
|
+
async buildClaim(intent, options = {}) {
|
|
170
|
+
if (await this.recoverClaim(intent))
|
|
171
|
+
throw new NativeClaimError("this request already completed; recover its receipt instead of building a transaction");
|
|
172
|
+
const built = await this.planClaim(intent, options.proof);
|
|
173
|
+
const { prepared } = await prepareNative(this.context, built.plan, options);
|
|
174
|
+
return { ...prepared, intent: built.intent, plan: built.plan };
|
|
175
|
+
}
|
|
176
|
+
async claim(intent, options = {}) {
|
|
177
|
+
// Snapshot before any awaits: callers cannot mutate reviewed destination/amount mid-flow.
|
|
178
|
+
const snapshot = claimIntentFromNative(scValToNative(claimIntentToScVal(intent)));
|
|
179
|
+
return this.context.serialize(async () => { const old = await this.recoverClaim(snapshot); if (old)
|
|
180
|
+
return { status: "replayed", receipt: old, transaction: null }; const { plan } = await this.planClaim(snapshot, options.proof); const result = await sendNative(this.context, plan, options); return confirmedResult(result, "claim", claimIntentToScVal(snapshot), snapshot.claimant); });
|
|
181
|
+
}
|
|
182
|
+
async renewalPreview(name) {
|
|
183
|
+
const parsed = names(name), registrar = await this.registrar(parsed.namespace);
|
|
184
|
+
const raw = exactObject(await this.context.read(registrar, "record_of", [claimLabelToScVal(parsed.label)]), ["holder", "address", "expires_at", "generation"], "name record");
|
|
185
|
+
const resolver = address(await this.context.read(this.context.registryId, "resolver_of", [sc.bytes(namespaceNode(parsed.namespace))]), "contract", "Resolver");
|
|
186
|
+
const result = destinationPreviewFromNative(await this.context.read(resolver, "preview_destination", [xdr.ScVal.scvString(`${parsed.label}.${parsed.namespace}`), sc.u64(u64(raw.generation, "name generation"))]));
|
|
187
|
+
if (result.holder !== raw.holder || result.generation !== raw.generation || result.expiresAt !== raw.expires_at)
|
|
188
|
+
throw new NativeClaimError("renewal preview no longer matches name state", "unavailable");
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
async acceptNameTransferWithDestination(input, options = {}) {
|
|
192
|
+
const encoded = transferIntentToScVal(input);
|
|
193
|
+
return this.lifecycle("accept_transfer_with_destination", input.context, input.to, encoded, input.label, initialization(input.resolver, input.label, input.to, input.expectedGeneration + 1n, input.destination), options);
|
|
194
|
+
}
|
|
195
|
+
async renewName(input, options = {}) { return this.lifecycle("renew_holder", input.context, input.holder, renewIntentToScVal(input), input.label, null, options); }
|
|
196
|
+
async lifecycle(method, request, holder, intent, nameLabel, child, options) {
|
|
197
|
+
request = { ...request };
|
|
198
|
+
scoped(this.context, request);
|
|
199
|
+
return this.context.serialize(async () => {
|
|
200
|
+
await verifyRegistrarProvenance(this.context, request.registrar, request.namespace);
|
|
201
|
+
if (await this.context.read(this.context.registryId, "registrar_of", [sc.bytes(unhex(request.namespace))]) !== request.registrar)
|
|
202
|
+
throw new NativeClaimError("recovery Registrar is not the Registry attestation", "unavailable");
|
|
203
|
+
const old = await this.receiptAt(request.registrar, holder, request.requestId);
|
|
204
|
+
if (old) {
|
|
205
|
+
receiptMatches(old, method, intent, holder);
|
|
206
|
+
return { status: "replayed", receipt: old, transaction: null };
|
|
207
|
+
}
|
|
208
|
+
await this.checkRegistrar(request.registrar, request.namespace);
|
|
209
|
+
if (child) {
|
|
210
|
+
const pair = await this.context.read(this.context.registryId, "native_contracts", [sc.bytes(unhex(request.namespace))]);
|
|
211
|
+
if (!Array.isArray(pair) || pair.length !== 2 || pair[0] !== request.registrar || pair[1] !== child.contract)
|
|
212
|
+
throw new NativeClaimError("transfer initializer differs from clean native bindings", "unavailable");
|
|
213
|
+
}
|
|
214
|
+
const source = address(await this.context.signer.publicKey(), "account", "holder source");
|
|
215
|
+
if (source !== holder)
|
|
216
|
+
throw new NativeClaimError("connected wallet is not the intended holder/recipient", "authorization");
|
|
217
|
+
const result = await sendNative(this.context, { source, contract: request.registrar, method, args: [intent], sourceInvocation: { contract: request.registrar, method, args: [intent], children: child ? [child] : [] }, maxFeeStroops: this.context.maxFeeStroops }, options);
|
|
218
|
+
return confirmedResult(result, method, intent, holder);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Transaction, rpc, xdr } from "@stellar/stellar-sdk";
|
|
2
|
+
import { type NativeAuthorizationPlan } from "./native-auth.js";
|
|
3
|
+
export type NativeSigner = {
|
|
4
|
+
publicKey(): string | Promise<string>;
|
|
5
|
+
signTransaction(encoded: string, opts: {
|
|
6
|
+
networkPassphrase: string;
|
|
7
|
+
}): Promise<string | {
|
|
8
|
+
signedTxXdr: string;
|
|
9
|
+
}>;
|
|
10
|
+
};
|
|
11
|
+
export type NativeContext = {
|
|
12
|
+
registryId: string;
|
|
13
|
+
passphrase: string;
|
|
14
|
+
server: rpc.Server;
|
|
15
|
+
signer: NativeSigner;
|
|
16
|
+
fee: string;
|
|
17
|
+
timeoutSecs: number;
|
|
18
|
+
maxFeeStroops: bigint;
|
|
19
|
+
read(contract: string, method: string, args: xdr.ScVal[]): Promise<unknown>;
|
|
20
|
+
serialize<T>(work: () => Promise<T>): Promise<T>;
|
|
21
|
+
};
|
|
22
|
+
export type NativeCapability = {
|
|
23
|
+
supported: false;
|
|
24
|
+
registrar: string;
|
|
25
|
+
reason: "legacy-template" | "unsupported-version";
|
|
26
|
+
} | {
|
|
27
|
+
supported: true;
|
|
28
|
+
version: 1;
|
|
29
|
+
registrar: string;
|
|
30
|
+
resolver: string;
|
|
31
|
+
registry: string;
|
|
32
|
+
namespace: string;
|
|
33
|
+
owner: string;
|
|
34
|
+
ownerEpoch: bigint;
|
|
35
|
+
};
|
|
36
|
+
/** Historical reads must not trust an upgraded Registrar that can fabricate receipts. Resolver changes do not block this Registrar-only check. */
|
|
37
|
+
export declare function verifyRegistrarProvenance(context: NativeContext, registrar: string, namespace: string): Promise<void>;
|
|
38
|
+
export declare function nativeCapability(context: NativeContext, namespace: string): Promise<NativeCapability>;
|
|
39
|
+
export declare function requireNative(context: NativeContext, namespace: string): Promise<Extract<NativeCapability, {
|
|
40
|
+
supported: true;
|
|
41
|
+
}>>;
|
|
42
|
+
export type NativePrepared = {
|
|
43
|
+
transactionXdr: string;
|
|
44
|
+
hash: string;
|
|
45
|
+
networkPassphrase: string;
|
|
46
|
+
feeStroops: bigint;
|
|
47
|
+
eligibilityEntryXdr: string | null;
|
|
48
|
+
};
|
|
49
|
+
export type NativeWriteOptions = {
|
|
50
|
+
/** Exact native admission authorization from the app. This is not an owner transaction signature. */
|
|
51
|
+
eligibilityAuthorization?: string;
|
|
52
|
+
/** Persist the public recovery reference before wallet signing/broadcast. Rejecting stops submission. */
|
|
53
|
+
onPrepared?: (prepared: NativePrepared) => void | Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
export declare function prepareNative(context: NativeContext, plan: NativeAuthorizationPlan, options?: NativeWriteOptions): Promise<{
|
|
56
|
+
transaction: Transaction;
|
|
57
|
+
prepared: NativePrepared;
|
|
58
|
+
}>;
|
|
59
|
+
/** No automatic transaction retry, implicit restoration or hash-losing error path. */
|
|
60
|
+
export declare function sendNative(context: NativeContext, plan: NativeAuthorizationPlan, options?: NativeWriteOptions): Promise<{
|
|
61
|
+
hash: string;
|
|
62
|
+
ledger: number;
|
|
63
|
+
value: unknown;
|
|
64
|
+
}>;
|
|
65
|
+
/** Bind terminal RPC context before classifying success or failure. An incomplete response remains uncertain. */
|
|
66
|
+
export declare function validateNativeTerminalReceipt(receipt: rpc.Api.GetSuccessfulTransactionResponse | rpc.Api.GetFailedTransactionResponse, expectedHash: string, passphrase: string): void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Contract, Operation, Transaction, TransactionBuilder, rpc, scValToNative, xdr } from "@stellar/stellar-sdk";
|
|
2
|
+
import { NativeClaimError, address, bytes32, hex, namespaceNode, sc, unhex } from "./native-codec.js";
|
|
3
|
+
import { assertSignedBodyUnchanged, validateEligibilityAuthorization, validateNativeTransaction } from "./native-auth.js";
|
|
4
|
+
/** This published legacy code has owner-only issuance. Missing RPC data is never legacy detection. */
|
|
5
|
+
const LEGACY_REGISTRAR_HASH = "ed06b3374ff4342b4a2316fc546505132cd8fd35be6666571cf088710af9bbc6";
|
|
6
|
+
/** Historical reads must not trust an upgraded Registrar that can fabricate receipts. Resolver changes do not block this Registrar-only check. */
|
|
7
|
+
export async function verifyRegistrarProvenance(context, registrar, namespace) {
|
|
8
|
+
const node = sc.bytes(unhex(namespace));
|
|
9
|
+
const [attested, tainted, templates, instance] = await Promise.all([
|
|
10
|
+
context.read(context.registryId, "registrar_of", [node]), context.read(context.registryId, "registrar_tainted", [node]),
|
|
11
|
+
context.read(context.registryId, "template_hashes", []), context.server.getContractInstance(registrar),
|
|
12
|
+
]);
|
|
13
|
+
if (attested !== registrar || tainted !== false || !Array.isArray(templates) || templates.length !== 2 || instance.executable.type !== "contractExecutableWasm")
|
|
14
|
+
throw new NativeClaimError("Registrar provenance is unavailable or tainted; refusing authoritative claim/receipt reads", "unavailable");
|
|
15
|
+
if (hex(bytes32(templates[0], "Registry Registrar template")) !== hex(instance.executable.wasmHash.value))
|
|
16
|
+
throw new NativeClaimError("Registrar executable differs from immutable Registry template", "unavailable");
|
|
17
|
+
}
|
|
18
|
+
export async function nativeCapability(context, namespace) {
|
|
19
|
+
const node = namespaceNode(namespace), nodeArg = sc.bytes(node);
|
|
20
|
+
const registrar = address(await context.read(context.registryId, "registrar_of", [nodeArg]), "contract", "namespace Registrar");
|
|
21
|
+
const instance = await context.server.getContractInstance(registrar);
|
|
22
|
+
if (instance.executable.type === "contractExecutableWasm" && hex(instance.executable.wasmHash.value) === LEGACY_REGISTRAR_HASH)
|
|
23
|
+
return { supported: false, registrar, reason: "legacy-template" };
|
|
24
|
+
await verifyRegistrarProvenance(context, registrar, hex(node));
|
|
25
|
+
const version = await context.read(registrar, "claim_version", []);
|
|
26
|
+
if (typeof version !== "number" || !Number.isInteger(version))
|
|
27
|
+
throw new NativeClaimError("malformed native claim capability", "unavailable");
|
|
28
|
+
if (version !== 1)
|
|
29
|
+
return { supported: false, registrar, reason: "unsupported-version" };
|
|
30
|
+
const [pair, epoch, owner, anchors] = await Promise.all([
|
|
31
|
+
context.read(context.registryId, "native_contracts", [nodeArg]), context.read(context.registryId, "owner_epoch", [nodeArg]),
|
|
32
|
+
context.read(context.registryId, "owner_of", [nodeArg]), context.read(registrar, "anchors", []),
|
|
33
|
+
]);
|
|
34
|
+
if (!Array.isArray(pair) || pair.length !== 2 || pair[0] !== registrar)
|
|
35
|
+
throw new NativeClaimError("native contract binding does not match Registrar", "unavailable");
|
|
36
|
+
const resolver = address(pair[1], "contract", "native Resolver");
|
|
37
|
+
if (!Array.isArray(anchors) || anchors.length !== 2 || anchors[0] !== context.registryId || !(anchors[1] instanceof Uint8Array) || hex(anchors[1]) !== hex(node))
|
|
38
|
+
throw new NativeClaimError("Registrar anchors differ from selected namespace", "unavailable");
|
|
39
|
+
if (typeof epoch !== "bigint" || epoch < 1n)
|
|
40
|
+
throw new NativeClaimError("invalid namespace ownership epoch", "unavailable");
|
|
41
|
+
if (await context.read(resolver, "initialization_version", []) !== 1)
|
|
42
|
+
throw new NativeClaimError("unsupported native Resolver initializer", "unsupported");
|
|
43
|
+
return { supported: true, version: 1, registrar, resolver, registry: context.registryId, namespace, owner: address(owner, "identity", "namespace owner"), ownerEpoch: epoch };
|
|
44
|
+
}
|
|
45
|
+
export async function requireNative(context, namespace) {
|
|
46
|
+
const capability = await nativeCapability(context, namespace);
|
|
47
|
+
if (!capability.supported)
|
|
48
|
+
throw new NativeClaimError(`namespace has no supported native claim interface (${capability.reason})`, "unsupported");
|
|
49
|
+
return capability;
|
|
50
|
+
}
|
|
51
|
+
export async function prepareNative(context, plan, options = {}) {
|
|
52
|
+
const source = address(await context.signer.publicKey(), "account", "transaction signer");
|
|
53
|
+
if (source !== plan.source)
|
|
54
|
+
throw new NativeClaimError("wallet changed since the intent was reviewed", "authorization");
|
|
55
|
+
const raw = new TransactionBuilder(await context.server.getAccount(source), { fee: context.fee, networkPassphrase: context.passphrase })
|
|
56
|
+
.addOperation(new Contract(plan.contract).call(plan.method, ...plan.args)).setTimeout(context.timeoutSecs).build();
|
|
57
|
+
const simulate = async (tx) => {
|
|
58
|
+
const result = await context.server.simulateTransaction(tx, undefined, undefined, false);
|
|
59
|
+
if (rpc.Api.isSimulationRestore(result))
|
|
60
|
+
throw new NativeClaimError("native operation requires storage restoration; review and restore separately before resuming this intent", "unavailable");
|
|
61
|
+
if (rpc.Api.isSimulationError(result))
|
|
62
|
+
throw new NativeClaimError(`${plan.method}: ${result.error}`, "failed");
|
|
63
|
+
if (!rpc.Api.isSimulationSuccess(result) || !result.result)
|
|
64
|
+
throw new NativeClaimError("native simulation returned no result", "unavailable");
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
let transaction = rpc.assembleTransaction(raw, await simulate(raw)).build();
|
|
68
|
+
validateNativeTransaction(transaction, plan, false);
|
|
69
|
+
if (options.eligibilityAuthorization) {
|
|
70
|
+
if (options.eligibilityAuthorization.length > 32768)
|
|
71
|
+
throw new NativeClaimError("eligibility authorization is too large", "authorization");
|
|
72
|
+
if (!plan.eligibility)
|
|
73
|
+
throw new NativeClaimError("this native operation does not require an app approver", "authorization");
|
|
74
|
+
const signed = xdr.SorobanAuthorizationEntry.fromXDR(options.eligibilityAuthorization, "base64");
|
|
75
|
+
validateEligibilityAuthorization(signed, plan.eligibility);
|
|
76
|
+
const op = transaction.operations[0];
|
|
77
|
+
if (op.type !== "invokeHostFunction")
|
|
78
|
+
throw new NativeClaimError("unexpected prepared operation");
|
|
79
|
+
const auth = (op.auth ?? []).map(entry => entry.credentials.type === "sorobanCredentialsSourceAccount" ? entry : signed);
|
|
80
|
+
transaction = TransactionBuilder.cloneFrom(transaction, { networkPassphrase: context.passphrase }).clearOperations().addOperation(Operation.invokeHostFunction({ func: op.func, auth })).build();
|
|
81
|
+
transaction = rpc.assembleTransaction(transaction, await simulate(transaction)).build();
|
|
82
|
+
validateNativeTransaction(transaction, plan);
|
|
83
|
+
}
|
|
84
|
+
const op = transaction.operations[0];
|
|
85
|
+
if (op.type !== "invokeHostFunction")
|
|
86
|
+
throw new NativeClaimError("unexpected native operation");
|
|
87
|
+
const approval = (op.auth ?? []).find(entry => entry.credentials.type !== "sorobanCredentialsSourceAccount");
|
|
88
|
+
return { transaction, prepared: { transactionXdr: transaction.toXDR(), hash: hex(transaction.hash()), networkPassphrase: context.passphrase, feeStroops: BigInt(transaction.fee), eligibilityEntryXdr: approval?.toXDR("base64") ?? null } };
|
|
89
|
+
}
|
|
90
|
+
/** No automatic transaction retry, implicit restoration or hash-losing error path. */
|
|
91
|
+
export async function sendNative(context, plan, options = {}) {
|
|
92
|
+
const { transaction, prepared } = await prepareNative(context, plan, options);
|
|
93
|
+
validateNativeTransaction(transaction, plan);
|
|
94
|
+
await options.onPrepared?.(prepared);
|
|
95
|
+
if (await context.signer.publicKey() !== plan.source)
|
|
96
|
+
throw new NativeClaimError("wallet changed before signing", "authorization");
|
|
97
|
+
const result = await context.signer.signTransaction(prepared.transactionXdr, { networkPassphrase: context.passphrase });
|
|
98
|
+
const signed = assertSignedBodyUnchanged(transaction, typeof result === "string" ? result : result.signedTxXdr, context.passphrase);
|
|
99
|
+
try {
|
|
100
|
+
const sent = await context.server.sendTransaction(signed);
|
|
101
|
+
if (sent.hash !== prepared.hash)
|
|
102
|
+
throw new NativeClaimError("RPC returned a different transaction hash", "pending", prepared.hash);
|
|
103
|
+
if (sent.status === "ERROR" || sent.status === "TRY_AGAIN_LATER")
|
|
104
|
+
throw new NativeClaimError(`transaction was not accepted (${sent.status}); reconcile the original receipt and hash`, "pending", prepared.hash);
|
|
105
|
+
for (let attempt = 0; attempt < context.timeoutSecs + 5; attempt++) {
|
|
106
|
+
const receipt = await context.server.getTransaction(prepared.hash);
|
|
107
|
+
if (receipt.status === "SUCCESS" || receipt.status === "FAILED") {
|
|
108
|
+
// Status alone cannot identify the reviewed transaction or establish inclusion.
|
|
109
|
+
validateNativeTerminalReceipt(receipt, prepared.hash, context.passphrase);
|
|
110
|
+
if (receipt.status === "SUCCESS")
|
|
111
|
+
return { hash: prepared.hash, ledger: receipt.ledger, value: receipt.returnValue ? scValToNative(receipt.returnValue) : null };
|
|
112
|
+
throw new NativeClaimError("native transaction failed on chain", "failed", prepared.hash);
|
|
113
|
+
}
|
|
114
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
115
|
+
}
|
|
116
|
+
throw new NativeClaimError("transaction outcome is unknown; recover the original receipt and hash before replacing the request", "pending", prepared.hash);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof NativeClaimError && error.txHash)
|
|
120
|
+
throw error;
|
|
121
|
+
throw new NativeClaimError(`submission/confirmation interrupted: ${String(error)}; reconcile the original receipt`, "pending", prepared.hash);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Bind terminal RPC context before classifying success or failure. An incomplete response remains uncertain. */
|
|
125
|
+
export function validateNativeTerminalReceipt(receipt, expectedHash, passphrase) {
|
|
126
|
+
try {
|
|
127
|
+
if (receipt.txHash !== expectedHash || !Number.isInteger(receipt.ledger) || receipt.ledger <= 0 || receipt.ledger > 0xffff_ffff || !receipt.envelopeXdr)
|
|
128
|
+
throw new Error("missing or mismatched hash/ledger/envelope");
|
|
129
|
+
const included = TransactionBuilder.fromXDR(receipt.envelopeXdr, passphrase);
|
|
130
|
+
if (!(included instanceof Transaction) || hex(included.hash()) !== expectedHash)
|
|
131
|
+
throw new Error("different included envelope");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
throw new NativeClaimError("terminal RPC response does not prove inclusion of the original reviewed transaction; reconcile its hash", "pending", expectedHash);
|
|
135
|
+
}
|
|
136
|
+
}
|