Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion example/safe_inscription.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const { MixinApi } = require('..');
const keystore = require('../keystore.json');
const { v4 } = require('uuid');

const main = async () => {
console.log(keystore);
Expand All @@ -15,6 +14,25 @@ const main = async () => {
// for (const item of items) {
// console.log('item: ', item);
// }

// the spend private key defaults to keystore.spend_private_key
// const results = await client.safe.transferInscription({
// inscriptionHash: '94d20f04829dcfb2c6d3cdb7ba94b3f6b402eb0537d6aa48f76e14d21e84c784',
// receivers: ['7766b24c-1a03-4c3a-83a3-b4358266875d'],
// threshold: 1,
// memo: 'enjoy your collectible',
// });
// console.log('transfer results: ', results);

// or spend a known inscription output directly
// const outputs = await client.utxo.safeOutputs({ state: 'unspent' });
// const utxo = outputs.find(o => o.inscription_hash);
// const results = await client.safe.transferInscription({
// utxo,
// receivers: ['7766b24c-1a03-4c3a-83a3-b4358266875d'],
// threshold: 1,
// });
// console.log('transfer results: ', results);
};

main();
14 changes: 13 additions & 1 deletion src/client/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import type {
SafeSnapshot,
SafeSnapshotsRequest,
SafeWithdrawalFee,
SequencerTransactionRequest,
} from './types';
import { buildClient, signEd25519PIN, signSafeRegistration } from './utils';
import { buildClient, signEd25519PIN, signSafeRegistration, transferInscription, type SafeTransferInscriptionRequest } from './utils';
import { UtxoKeystoreClient } from './utxo';

export const SafeKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keystore | undefined) => ({
/** If you want to register safe user, you need to upgrade TIP PIN first. */
Expand Down Expand Up @@ -59,6 +61,16 @@ export const SafeKeystoreClient = (axiosInstance: AxiosInstance, keystore: Keyst
axiosInstance.get<unknown, SafeCollectible[]>(`/safe/inscriptions/collections/${collectionHash}/items`, {
params: offset && offset > 0 ? { offset } : undefined,
}),

/**
* Transfer an inscription (collectible) to a recipient. The spend private
* key defaults to the spend_private_key in the keystore.
*/
transferInscription: (data: SafeTransferInscriptionRequest): Promise<SequencerTransactionRequest[]> => {
const spendPrivateKey = data.spendPrivateKey ?? (keystore as { spend_private_key?: string } | undefined)?.spend_private_key;
if (!spendPrivateKey) return Promise.reject(new Error('spend private key is required, set it in the keystore or pass spendPrivateKey'));
return transferInscription(UtxoKeystoreClient(axiosInstance), { ...data, spendPrivateKey });
},
});
export const SafeClient = buildClient(SafeKeystoreClient);

Expand Down
1 change: 1 addition & 0 deletions src/client/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './conversation';
export * from './error';
export * from './external';
export * from './invoice';
export * from './inscription';
export * from './keystore';
export * from './message';
export * from './mixin_transaction';
Expand Down
37 changes: 37 additions & 0 deletions src/client/types/inscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export type InscriptionMode = 1 | 2;

export interface InscriptionTreasury {
ratio: string;
recipient: string;
}

export interface InscriptionDeploy {
version: 1;
mode: InscriptionMode;
supply: string;
unit: string;
symbol: string;
name: string;
icon: string;
checksum?: string;
treasury?: InscriptionTreasury;
}

export interface InscriptionInscribe {
operation: 'inscribe';
recipient: string;
content?: string;
}

export interface InscriptionDistribute {
/** the JSON key aligns with the Go SDK tag json:"distribute", the value must be the literal "distribute" */
distribute: 'distribute';
sequence: number;
}

export interface InscriptionOccupy {
operation: 'occupy';
sequence: number;
}

export type InscriptionOperation = InscriptionDeploy | InscriptionInscribe | InscriptionDistribute | InscriptionOccupy;
1 change: 1 addition & 0 deletions src/client/types/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export interface AppKeystore {
session_id: string;
server_public_key: string;
session_private_key: string;
spend_private_key?: string;
}

export interface OAuthKeystore {
Expand Down
4 changes: 3 additions & 1 deletion src/client/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export type MessageCategory =
| 'APP_BUTTON_GROUP'
| 'MESSAGE_RECALL'
| 'SYSTEM_CONVERSATION'
| 'SYSTEM_ACCOUNT_SNAPSHOT';
| 'SYSTEM_ACCOUNT_SNAPSHOT'
| 'SYSTEM_SAFE_SNAPSHOT'
| 'SYSTEM_SAFE_INSCRIPTION';

export type EncryptedMessageStatus = 'SUCCESS' | 'FAILED';

Expand Down
1 change: 1 addition & 0 deletions src/client/types/multisig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,5 @@ export interface SafeMultisigsResponse {
created_at: string;
updated_at: string;
views: string[];
inscription_hash?: string;
}
3 changes: 3 additions & 0 deletions src/client/types/safe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MixAddress } from './address';
import type { InscriptionTreasury } from './inscription';

// field for:
// GET safe/assets
Expand Down Expand Up @@ -77,6 +78,7 @@ export interface SafeSnapshot {
closing_balance: string | null;
deposit: SafeDeposit | null;
withdrawal: SafeWithdrawal | null;
inscription_hash?: string;
}

export interface SafeDeposit {
Expand Down Expand Up @@ -120,6 +122,7 @@ export interface SafeCollection {
symbol: string;
type: string;
unit: string;
treasury?: InscriptionTreasury;
created_at: string;
updated_at: string;
}
Expand Down
4 changes: 4 additions & 0 deletions src/client/types/utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,8 @@ export interface PaymentParams {
memo?: string;
trace?: string;
returnTo?: string;
/** hash of the inscription (NFT collectible) to be paid to the destination */
inscription?: string;
/** hash of the inscription collection, lets the payer pick a collectible from it */
inscriptionCollection?: string;
}
1 change: 1 addition & 0 deletions src/client/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './computer';
export * from './decoder';
export * from './ed25519';
export * from './encoder';
export * from './inscription';
export * from './invoice';
export * from './multisigs';
export * from './nfo';
Expand Down
125 changes: 125 additions & 0 deletions src/client/utils/inscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { v4 } from 'uuid';
import type {
GhostKey,
InscriptionDeploy,
InscriptionDistribute,
InscriptionInscribe,
InscriptionOccupy,
InscriptionOperation,
SafeOutputsRequest,
SafeTransactionRecipient,
SafeUtxoOutput,
SequencerTransactionRequest,
TransactionRequest,
} from '../types';
import { buildSafeTransaction, buildSafeTransactionRecipient, encodeSafeTransaction, signSafeTransaction } from './safe';

export const InscriptionModeInstant = 1;
export const InscriptionModeDone = 2;

export interface InscriptionUtxoClient {
safeOutputs: (params: SafeOutputsRequest) => Promise<SafeUtxoOutput[]>;
ghostKey: (recipients: SafeTransactionRecipient[], trace: string, spendPrivateKey: string) => Promise<(GhostKey | undefined)[]>;
verifyTransaction: (params: TransactionRequest[]) => Promise<SequencerTransactionRequest[]>;
sendTransactions: (params: TransactionRequest[]) => Promise<SequencerTransactionRequest[]>;
}

export const buildInscriptionOperationExtra = (operation: InscriptionOperation): Buffer => Buffer.from(JSON.stringify(operation), 'utf8');

export const decodeInscriptionOperationExtra = (extra: Buffer | string): InscriptionOperation => {
const data = typeof extra === 'string' ? Buffer.from(extra, 'hex') : extra;
let operation: unknown;
try {
operation = JSON.parse(data.toString('utf8'));
} catch {
throw new Error('invalid inscription operation extra');
}
if (!operation || typeof operation !== 'object') throw new Error('invalid inscription operation extra');

const op = operation as Record<string, unknown>;
if (op.operation === 'inscribe' && typeof op.recipient === 'string') return operation as InscriptionInscribe;
if (op.distribute === 'distribute' && Number.isInteger(op.sequence)) return operation as InscriptionDistribute;
if (op.operation === 'occupy' && Number.isInteger(op.sequence)) return operation as InscriptionOccupy;
if (op.version === 1 && (op.mode === InscriptionModeInstant || op.mode === InscriptionModeDone)) return operation as InscriptionDeploy;
throw new Error('unknown inscription operation');
};

export interface InscriptionOutputRequest {
members?: string[];
threshold?: number;
limit?: number;
}

export const getInscriptionOutput = async (utxo: InscriptionUtxoClient, inscriptionHash: string, request: InscriptionOutputRequest = {}): Promise<SafeUtxoOutput> => {
const { members, threshold, limit = 500 } = request;

let offset: number | undefined;
for (;;) {
const outputs = await utxo.safeOutputs({
members,
threshold,
state: 'unspent',
offset,
limit,
});

const found = outputs.find(o => o.inscription_hash === inscriptionHash);
if (found) return found;

if (outputs.length < limit) break;
offset = outputs[outputs.length - 1].sequence;
}

throw new Error(`unspent inscription output not found: ${inscriptionHash}`);
};

export interface TransferInscriptionParams {
inscriptionHash?: string;
utxo?: SafeUtxoOutput;
receivers: string[];
threshold: number;
spendPrivateKey: string;
memo?: string;
signerIndex?: number;
request_id?: string;
members?: string[];
ownershipThreshold?: number;
}

export type SafeTransferInscriptionRequest = Omit<TransferInscriptionParams, 'spendPrivateKey'> & {
/** defaults to the spend_private_key in the keystore */
spendPrivateKey?: string;
};

/**
* Transfer an inscription to a recipient by spending the inscription output
* entirely: inscriptions can't be split or merged, and no change is allowed.
*/
export const transferInscription = async (utxo: InscriptionUtxoClient, params: TransferInscriptionParams): Promise<SequencerTransactionRequest[]> => {
let utxoOutput = params.utxo;
if (!utxoOutput) {
if (!params.inscriptionHash) throw new Error('either utxo or inscriptionHash is required to transfer an inscription');
utxoOutput = await getInscriptionOutput(utxo, params.inscriptionHash, {
members: params.members,
threshold: params.ownershipThreshold,
});
}

if (!utxoOutput.inscription_hash) throw new Error('the output does not carry an inscription');
if (params.inscriptionHash && utxoOutput.inscription_hash !== params.inscriptionHash) throw new Error('inscription hash mismatch');
if (utxoOutput.state !== 'unspent') throw new Error(`the inscription output is ${utxoOutput.state}`);

const request_id = params.request_id ?? v4();
const extra = params.memo ? Buffer.from(params.memo, 'utf8') : Buffer.alloc(0);
const recipients = [buildSafeTransactionRecipient(params.receivers, params.threshold, utxoOutput.amount)];

const ghosts = await utxo.ghostKey(recipients, request_id, params.spendPrivateKey);
const tx = buildSafeTransaction([utxoOutput], recipients, ghosts, extra);
const raw = encodeSafeTransaction(tx);

const verified = await utxo.verifyTransaction([{ raw, request_id }]);
if (!verified[0]?.views || verified[0].views.length < tx.inputs.length) throw new Error('invalid views to sign the inscription transaction');

const signedRaw = signSafeTransaction(tx, verified[0].views, params.spendPrivateKey, params.signerIndex ?? 0);
return utxo.sendTransactions([{ raw: signedRaw, request_id }]);
};
5 changes: 4 additions & 1 deletion src/client/utils/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export const buildMixinOneSafePaymentUri = (params: PaymentParams) => {
asset: params.asset,
amount: params.amount,
memo: params.memo,
inscription: params.inscription,
inscription_collection: params.inscriptionCollection,
trace: params.trace ?? v4(),
return_to: params.returnTo && encodeURIComponent(params.returnTo),
};
Expand Down Expand Up @@ -91,13 +93,14 @@ export const deriveGhostPublicKey = (r: Buffer, A: Buffer, B: Buffer, index: num
return Buffer.from(p4.toBytes());
};

export const getUnspentOutputsForRecipients = (outputs: SafeUtxoOutput[], rs: SafeTransactionRecipient[]) => {
export const getUnspentOutputsForRecipients = (outputs: SafeUtxoOutput[], rs: SafeTransactionRecipient[], options: { includeInscriptions?: boolean } = {}) => {
const totalOutput = rs.reduce((prev, cur) => prev.plus(BigNumber(cur.amount)), BigNumber('0'));

let totalInput = BigNumber('0');
const utxos: SafeUtxoOutput[] = [];
for (const o of outputs) {
if (o.state !== 'unspent') continue;
if (!options.includeInscriptions && o.inscription_hash) continue;
utxos.push(o);
totalInput = totalInput.plus(BigNumber(o.amount));
if (totalInput.minus(totalOutput).isNegative()) continue;
Expand Down
Loading
Loading