The Varnir SDK for PHP
varnir/sdk is the PHP port of @varnir/chain-client. Shared rules (amounts,
field order, $sigs, send results, recovery phrases, golden vectors) are on
SDK overview and backends.
Activeledger's PHP SDK signs ML-DSA-65 and secp256k1, not Falcon-512. A Falcon key is refused when the client is constructed rather than at the first signature, while you can still pick a different key.
The limit is narrow. Falcon is Varnir's client-side default, not a ledger requirement: the
onboard transaction names its own key type in $i.otk.type. So PHP generates
an ml-dsa-65 key, onboards with it, and then acts as that identity's
owner key for everything — Keys::generateKey() defaults to ml-dsa-65
for exactly that reason, where the TypeScript and Kotlin SDKs default to
Falcon.
What PHP cannot do is act for an identity somebody already onboarded with
a Falcon-512 owner key. For those, add a scoped ml-dsa-65 or secp256k1 key
to the identity from a client that can sign Falcon, and act as that key here.
Install
Activeledger's SDK comes from Packagist (activeledger/sdk ^2.4); no extra
repository is needed.
composer require varnir/sdk
PHP 8.1 or later.
Cryptography
Signing, key handling and canonical JSON (CanonicalJson::encode) come from
Activeledger's PHP SDK. Key generation and phrase recovery are
upstream's too (KeyPair::generate(), KeyPair::fromPhrase(),
Secp256k1KeyPair), wrapped only to put both halves into Varnir's encoding.
See Cryptography comes from Activeledger.
ML-DSA private keys are seeds
PHP's ML-DSA-65 private key is a 32-byte seed.
paragonie/pqcrypto_compat implements FIPS 204 key generation from a seed but
has no skEncode/skDecode, so there is no 4032-byte private-key form at all.
So fromSeedBase64 is the only way to bring an ML-DSA identity into PHP.
Exporting a PHP identity: share the seed, never the key
Because the 4032-byte ML-DSA private key form does not exist here, a PHP
identity has exactly one portable export: seedBase64(). That seed opens the
same identity in every other Activeledger SDK.
The inverse does not hold. A 4032-byte key from another SDK cannot be loaded here, and cannot be turned back into the seed it came from - the derivation is one-way. So if a key might ever need to reach PHP, the seed is the thing to keep, and keeping only the expanded key is a decision that cannot be undone later.
Use it
use Varnir\Client;
use Varnir\Keys;
// ml-dsa-65 by default - the TypeScript and Kotlin SDKs default to
// falcon-512, which this one would then refuse on the very next line.
$pair = Keys::generateKey();
// Idempotent. The identity id is a pure function of the public key
// (sha256(publicKey . 'identity'), the same name the onboard contract gives
// the stream), so this checks the ledger first and submits nothing if that
// key is already onboarded.
$client = Client::onboard(
privateKey: $pair->privateKey,
nodes: [$ledgerNodeUrl],
onboardContract: $onboardContractId,
gateways: [$gatewayUrl],
);
// Or act for an identity that already exists:
$client = Client::create(
identity: $myIdentity,
privateKey: $pair->privateKey, // ml-dsa-65 or secp256k1
nodes: [$gatewayUrl],
transferContract: $transferContractId,
);
$result = $client->send('niles', 'TSC9KBw...', '1.5');
if ($result->settled) {
echo "sent: {$result->umid}\n";
} elseif ($result->held) {
// Over the key's co-sign threshold. NO MONEY HAS MOVED.
echo "held for a co-signature — approval needed\n";
}
The same twelve words open the same identity in every Activeledger SDK:
// Recovery only - no Varnir SDK mints a phrase. Entirely upstream's
// derivation, so the key this returns is the one every other SDK derives
// from those words. Pass Keys::SECP256K1 for the classical key.
$pair = Keys::deriveKeyPairFromPhrase($twelveWords);
echo $pair->identity();
send() returns a SendResult; nothing is thrown when a send is held
(why).
What is covered
| Amounts | toRaw / fromRaw, string arithmetic only |
| Networks | aliases, ledger codes, native decimals, gas reserve |
| Keys | generation, phrase recovery, type detection, on-wire form, the Varnir identity hash |
| Errors | the full stable code taxonomy, with classification |
| Transactions | every $tx shape, byte-identical to the reference |
| Signing | delegated, dispatching on the key's own tag |
| Transport | gateway failover, the commit-decides submission rule, and the signed X-Varnir-* API path |
| Money | onboard, the ledger reads, send, transfer, consolidate |
| Keys (ledger) | add, remove, roles, scopes, spend policy, thresholds |
| Proposals | read, confirm and void a held treasury transfer |
| Wallets | list, claim and assign, L1 balances, deposit status |
| Exchange | orders, fills, matches, cancels, market orders, atomic swaps |
| Commerce | invoices, webhooks (and verifying a delivery), deposits, receipts, transactions |
nodes are the gateways; apiBaseUrl is the scanner origin for wallet,
invoice and webhook routes. See Two base URLs and
Where each method runs.
Not supported
- A phrase generator.
Keys::deriveKeyPairFromPhrase()recovers anml-dsa-65orsecp256k1key from twelve words using upstream's derivation; it does not mint a phrase. Identities created under Varnir's retired derivations cannot be recovered. See Recovery phrases. waitForTransferandwatchPendingApprovals, the two polling helpers.- A derived
getBalances. It returns the identity stream, not a list of numbers: deriving them properly needs the token-registry read and the reserved-order calculation, and a balance that omits what is already committed to your own open orders reads as spendable when it is not.
Beyond a send
The same client carries the exchange, the scanner API, key changes and the treasury flow.
use Varnir\OrderLeg;
use Varnir\SignedOrder;
// The same client, given the rest of its deployment ids at construction:
// apiBaseUrl, swapContract and keysContract.
$leg = new OrderLeg('niles', '1.5'); // chain, then a human decimal amount
// Signed locally - no ledger write here. The maker can be offline when it fills.
$order = $client->createOrder(give: $leg, want: $leg, expireMs: $expireMs);
$client->postOrder($order); // fanned out to every gateway's matcher
// Taking someone else's: the taker gives the order's want leg, and signs.
$resting = SignedOrder::fromArray($client->getOrderBook()[0]);
$umid = $client->fillOrder($resting);
// Scanner API, not the ledger - signed with X-Varnir-* headers over a fixed
// route tag rather than a $sigs envelope.
$wallet = $client->getWallet('niles');
$invoice = $client->createInvoice([$wallet->id], 'Invoice 1', '1.5');
// Or a new wallet of its own on each chain, so a payment can only be for
// this invoice. A native-coin invoice takes one chain; USDT can take several.
$invoice = $client->createInvoice([], 'Invoice 2', '1.5', 'USDT',
newWallets: ['chains' => ['sep', 'niles']]);
foreach ($invoice['wallets'] as $newWallet) {
echo "{$newWallet->chain}: {$newWallet->address}\n";
}
// A scoped key: the setup step the whole propose/confirm flow rests on.
$client->addKey($agentPublicKey, 'secp256k1');
// A held transfer, confirmed by a key that is not the one that raised it.
foreach ($client->listPendingApprovals() as $pending) {
if (!$pending->isExpired) {
$client->confirmTransfer($pending->id);
}
}
Payout speed on an L1 withdrawal
send() takes an optional $speed for a withdrawal that goes out over the
chain: PayoutSpeed::Economy (out within 24 hours, the default),
PayoutSpeed::Standard (within an hour) or PayoutSpeed::Instant (straight
away, on its own). The string values 'economy', 'standard' and
'instant' are accepted too.
use Varnir\PayoutSpeed;
$result = $client->send('niles', 'TSC9KBw...', '1.5', speed: PayoutSpeed::Instant);
echo $result->speed?->value; // 'instant'; 'economy' when omitted
var_dump($result->speedRecorded); // true, false, or null when omitted
Payout speeds are not built on the network yet. Every withdrawal still goes out immediately and is charged at the Standard rate, so for now the choice is only recorded:
- An unknown value throws
VarnirExceptionwithErrorCode::Validationbefore anything is sent, whichever route the send would have taken. - The speed is never signed into the
$tx. It changes neither the umid nor the repeat-payment fingerprint. speedis set only on an L1 withdrawal that actually went out. A ledger settlement, a held transfer and a proposal carry none.- An explicit speed is then posted to
POST /api/withdrawals/speed, signed with the identity's own onboarding key. An attacheduseApiKey()key is not used here, because the route accepts only the onboarding key. The call is best-effort: one retry on a 503 or a network error, and it never throws.speedRecordedisfalsewhen the speed could not be stored, including when noapiBaseUrlis set. That does not mean the send failed. An omitted speed sends no extra request and leavesspeedRecordednull.
Committed but not confirmed: OutcomeUnknown
send() can throw after the transaction already committed: if the stream it
created can't be read back in time (read-after-write lag, more likely under
Activeledger v5's P2P commit), the SDK doesn't know whether it moved money or
is held for co-sign. That's ErrorCode::OutcomeUnknown — never resend.
Confirm first:
use Varnir\ErrorCode;
use Varnir\VarnirException;
try {
$result = $client->send('niles', 'TSC9KBw...', '1.5', ref: 'invoice-42');
} catch (VarnirException $e) {
if ($e->errorCode === ErrorCode::OutcomeUnknown) {
echo "commit unknown: {$e->committedUmid}\n";
// $e->getMessage() names the created stream to check:
// $client->getTransferProposal($streamId)
// or look the committed umid up in the transaction history. Do not resend.
} else {
throw $e;
}
}
See the error taxonomy for the full table.
Verifying webhooks
A webhook receiver needs only the signing secret from
getWebhookSettings(). It does not need a client. Verify the raw body,
exactly as received, before you parse the JSON:
use Varnir\Webhooks;
$raw = file_get_contents('php://input');
if (!Webhooks::verifyRequest($secret, $raw, getallheaders())) {
http_response_code(401); // not from Varnir, or stale
exit;
}
$event = json_decode($raw, true);
verifyRequest() reads X-Varnir-Timestamp and X-Varnir-Signature
case-insensitively. It accepts a getallheaders() array, $_SERVER, a
PSR-7 getHeaders() array or a PSR-7 request. A header sent twice counts as
missing. Webhooks::verifySignature($secret, $body, $timestamp, $signature)
takes the two values directly. Both return false rather than throw when:
- the signature does not start with
v1=, - the timestamp is not whole unix seconds,
- the timestamp is more than
Webhooks::TOLERANCE_SECONDS(300) from your clock in either direction, - or the HMAC-SHA256 does not match.
The comparison is constant-time.
Language-specific notes
Amounts: PHP integers overflow
Amounts::toRaw('1.5', 18); // "1500000000000000000"
Amounts::toRaw('0.1', 0); // InvalidArgumentException: more precision than 0 decimals
PHP integers are 64-bit, so an 18-decimal amount overflows silently into a float. The arithmetic is string-based for that reason. See Amounts are strings.
Do not build transactions from associative arrays
Transactions are built with upstream's JsonObject, whose set() preserves
insertion order. A PHP associative array also preserves order until it is
merged, filtered or re-encoded, which is why it is not used. See
Field order is the contract and
$sigs is keyed by stream id.
Bitcoin addresses
Check a Bitcoin address before paying it. Networks are btc, btc-testnet4 and btc-regtest; anything else, an address for the other network, a bad checksum or a witness version we do not pay is refused with VALIDATION. Bech32 comes back lower-case. Amounts need nothing new: Bitcoin is 8 decimal places, so "0.0001" is "10000" satoshis, and send() returns the ledger umid as on every chain (core contract section 5.1). send() itself refuses a destination that is not valid for the requested network (EVM with its EIP-55 checksum, Tron, Bitcoin) with VALIDATION, before the Varnir-known lookup and before anything is signed (section 5.2). The cases are generated from packages/btc-core.
Bitcoin::isValid('btc-testnet4', 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx'); // true
Bitcoin::normalise('btc-testnet4', 'TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX'); // lower-case
Networks::family('btc-testnet4'); // 'bitcoin', even while the network is disabled
Tests
./copy-vectors.sh
composer install
./vendor/bin/phpunit
See Golden vectors.